Automatic Speech Recognition
NeMo
English
speech
audio
NeMo
parakeet
TDT
FastConformer
quantization
binary
1-bit
quantization-aware-training
knowledge-distillation
Eval Results (legacy)
Instructions to use rajb3/kea-b with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- NeMo
How to use rajb3/kea-b with NeMo:
import nemo.collections.asr as nemo_asr asr_model = nemo_asr.models.ASRModel.from_pretrained("rajb3/kea-b") transcriptions = asr_model.transcribe(["file.wav"]) - Notebooks
- Google Colab
- Kaggle
File size: 15,349 Bytes
5e7c298 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 | # load_binary.py: standalone loader for Kea-B, the 1-bit Parakeet-TDT-0.6B-v2 export.
#
# MIT License
#
# Copyright (c) 2026 ajbarryiii
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# This license covers this file only. The model weights it loads are a modified
# version of nvidia/parakeet-tdt-0.6b-v2 and are licensed CC-BY-4.0 (see LICENSE
# and NOTICE in the model repository).
"""Load and run Kea-B, the 1-bit Parakeet-TDT-0.6B-v2 export, with only NeMo (nemo_toolkit[asr] and
its torch), safetensors and soundfile. Standalone copy of the repository's binary_export.load_export,
so the model can be used without the training code.
python load_binary.py clip.wav [clip2.flac ...] # 16 kHz audio; prints one transcript per file
import sys; sys.path.insert(0, "kea-b") # directory holding this repository's files
from load_binary import load, transcribe
model = load("kea-b", device="cuda") # or "cpu"
print(transcribe(model, ["clip.wav"])) # list in -> list of str out
Format "parakeet-binary-v1" (one directory):
- export.safetensors: each of the 264 quantized encoder weights as "<name>.codes", one bit per
weight packed eight per byte (code j of a row is bit j % 8 of byte j // 8, least significant
bit first; bit 1 = +1, bit 0 = -1; unused tail bits 0; uint8 [out, ceil(in/8)]; pointwise
convolutions as their [out, in] matrix) and "<name>.scale", one FP32 scale per output row;
the weight is codes * scale. Every other state_dict tensor is FP16, except the
feature-extraction constants preprocessor.featurizer.{window,fb} (FP32) and integer BatchNorm
counters (int64).
- manifest.json: format, SHA-256 of export.safetensors and the three tokenizer files, the NeMo
model config, per-layer shapes and kinds, code balance, parameter and byte accounting,
training provenance.
- tokenizer/: the base model's SentencePiece files (tokenizer.model, tokenizer.vocab, vocab.txt).
load() rebuilds a plain NeMo EncDecRNNTBPEModel in FP32 with the dequantized weights. It does
not use 1-bit kernels and needs the same memory and compute as the FP32 original; no speed or
energy benefit is claimed.
Tested environment: NeMo 3.0.0, torch 2.11.0+cu128. The reported WERs were measured decoding on
an NVIDIA RTX 5090 (CUDA, strict FP32). Loading on the CPU was tested, decoding on the CPU was not.
"""
from __future__ import annotations
import contextlib
import hashlib
import json
import math
import sys
from pathlib import Path
import numpy as np
import torch
from safetensors.torch import load_file
FORMAT = "parakeet-binary-v1"
MANIFEST = "manifest.json"
WEIGHTS_FILE = "export.safetensors"
TOKENIZER_DIR = "tokenizer"
# config.tokenizer key -> file name in the tokenizer directory
TOKENIZER_FILES = {"model_path": "tokenizer.model", "vocab_path": "vocab.txt",
"spe_tokenizer_vocab": "tokenizer.vocab"}
MODEL_TARGET = "nemo.collections.asr.models.rnnt_bpe_models.EncDecRNNTBPEModel"
LAYER_KINDS = ("linear", "pointwise_conv1d")
SAMPLE_RATE = 16000
# As in the evaluation: inputs shorter than 30 ms are zero-padded to 30 ms (NeMo's per-feature
# normalization fails on a one-frame input). Every reported test utterance is longer.
MIN_DECODE_SAMPLES = 480
MAX_BATCH_SECONDS = 1200.0
# NeMo's greedy TDT decoder CUDA-graph mode used by the evaluation (native PyTorch CUDA graphs).
DECODER_GRAPHS_MODE = "no_while_loops"
_SHIFTS = torch.arange(8, dtype=torch.uint8)
def unpack_codes(packed: torch.Tensor, in_features: int) -> torch.Tensor:
"""uint8 [out, ceil(in/8)] -> int8 [out, in] in {-1, +1}; rejects a wrong dtype or shape and nonzero tail bits."""
if packed.dtype != torch.uint8 or packed.dim() != 2 or packed.shape[1] != math.ceil(in_features / 8):
raise ValueError(f"expected uint8 [out, {math.ceil(in_features / 8)}], got {packed.dtype} {tuple(packed.shape)}")
bits = ((packed[..., None] >> _SHIFTS.to(packed.device)) & 1).flatten(1)
if bits[:, in_features:].any():
raise ValueError("invalid packed codes: nonzero tail padding")
return (2 * bits[:, :in_features].to(torch.int8) - 1).to(torch.int8)
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1 << 20), b""):
digest.update(chunk)
return digest.hexdigest()
def load(directory: str | Path = ".", device: str | torch.device = "cpu"):
"""Rebuild the FP32 eval-mode NeMo EncDecRNNTBPEModel from a directory holding manifest.json,
export.safetensors and tokenizer/.
The manifest must name export.safetensors as the weights file and list exactly it and
tokenizer/tokenizer.model, tokenizer/vocab.txt and tokenizer/tokenizer.vocab; each file's
SHA-256 is checked against it. manifest.json itself is not hash-checked.
"""
from nemo.collections.asr.models import EncDecRNNTBPEModel
from omegaconf import OmegaConf, open_dict
directory = Path(directory).resolve()
manifest = json.loads((directory / MANIFEST).read_text())
if manifest["format"] != FORMAT:
raise ValueError(f"unknown export format {manifest['format']!r}")
if manifest["file"] != WEIGHTS_FILE:
raise ValueError(f"manifest names weights file {manifest['file']!r}, not {WEIGHTS_FILE!r}")
expected_files = {WEIGHTS_FILE, *(f"{TOKENIZER_DIR}/{f}" for f in TOKENIZER_FILES.values())}
if set(manifest["files"]) != expected_files or manifest["files"][WEIGHTS_FILE] != manifest["sha256"]:
raise ValueError(f"manifest is inconsistent: files {sorted(manifest['files'])}")
for rel, digest in manifest["files"].items():
if _sha256(directory / rel) != digest:
raise ValueError(f"{directory / rel} does not match the manifest SHA-256")
tensors = load_file(directory / WEIGHTS_FILE)
state: dict[str, torch.Tensor] = {}
for name, layer in manifest["quantized_layers"].items():
if layer["kind"] not in LAYER_KINDS:
raise ValueError(f"{name}: unknown layer kind {layer['kind']!r}")
out_features, in_features = layer["shape"]
scale = tensors.pop(f"{name}.scale")
if scale.dtype != torch.float32 or tuple(scale.shape) != (out_features,):
raise ValueError(f"{name}.scale: expected float32 [{out_features}], got {scale.dtype} {tuple(scale.shape)}")
# codes are +-1, so codes * scale is exact: every weight is +scale or -scale of its row.
weight = unpack_codes(tensors.pop(f"{name}.codes"), in_features).float() * scale[:, None]
if weight.shape != (out_features, in_features):
raise ValueError(f"{name}: shape {tuple(weight.shape)} != {layer['shape']}")
state[f"{name}.weight"] = weight.unsqueeze(2) if layer["kind"] == "pointwise_conv1d" else weight
if layer["bias"]:
state[f"{name}.bias"] = tensors.pop(f"{name}.bias")
state.update((k, v.float() if v.is_floating_point() else v) for k, v in tensors.items())
cfg = OmegaConf.create(manifest["config"])
if cfg.get("target") != MODEL_TARGET:
raise ValueError(f"unexpected model target {cfg.get('target')!r}")
with open_dict(cfg):
cfg.tokenizer.dir = str(directory / TOKENIZER_DIR)
for key, filename in TOKENIZER_FILES.items():
cfg.tokenizer[key] = str(directory / TOKENIZER_DIR / filename)
for ds in ("train_ds", "validation_ds", "test_ds"): # stored for reference; no dataloaders at load
if ds in cfg:
cfg[ds] = None
model = EncDecRNNTBPEModel(cfg=cfg)
model.load_state_dict(state, strict=True) # shape mismatches and missing/unexpected keys raise
return model.to(device=device, dtype=torch.float32).eval()
def _pin_decoder_graphs(model) -> None:
"""On CUDA, put NeMo's greedy TDT decoder in the evaluation's CUDA-graph mode (no_while_loops),
when this NeMo version exposes it and the decoder config allows CUDA graphs."""
computer = getattr(getattr(getattr(model, "decoding", None), "decoding", None), "decoding_computer", None)
if computer is None or not hasattr(computer, "force_cuda_graphs_mode") or not computer.allow_cuda_graphs:
return
mode = computer.cuda_graphs_mode
if mode is None or str(mode.value) != DECODER_GRAPHS_MODE:
computer.force_cuda_graphs_mode(DECODER_GRAPHS_MODE)
@contextlib.contextmanager
def _strict_fp32(device: torch.device):
"""Autocast off and TF32 off (matmul and cuDNN) for the duration; previous settings restored."""
matmul = torch.backends.cuda.matmul.allow_tf32
precision = torch.get_float32_matmul_precision()
try:
torch.backends.cuda.matmul.allow_tf32 = False
torch.set_float32_matmul_precision("highest")
with torch.autocast(device.type, enabled=False), torch.backends.cudnn.flags(
enabled=True, benchmark=torch.backends.cudnn.benchmark,
deterministic=torch.backends.cudnn.deterministic, allow_tf32=False):
yield
finally:
torch.backends.cuda.matmul.allow_tf32 = matmul
torch.set_float32_matmul_precision(precision)
@contextlib.contextmanager
def _inference_settings(model):
"""What NeMo's transcribe() sets (eval mode, dither 0, pad_to 0), restored on exit."""
featurizer = getattr(getattr(model, "preprocessor", None), "featurizer", None)
saved = {k: getattr(featurizer, k) for k in ("dither", "pad_to") if hasattr(featurizer, k)}
training = model.training
try:
for key in saved:
setattr(featurizer, key, 0.0 if key == "dither" else 0)
model.eval()
with torch.no_grad():
yield
finally:
for key, value in saved.items():
setattr(featurizer, key, value)
model.train(training)
def read_audio(path: str | Path) -> np.ndarray:
"""float32 mono samples of a 16 kHz audio file; multichannel files are averaged to mono."""
import soundfile
audio, rate = soundfile.read(str(path), dtype="float32", always_2d=True)
if rate != SAMPLE_RATE:
raise ValueError(f"{path}: {rate} Hz; convert with: ffmpeg -i {path} -ar 16000 -ac 1 out.wav")
return audio.mean(axis=1) if audio.shape[1] > 1 else audio[:, 0]
def _as_clip(item) -> np.ndarray:
if isinstance(item, (str, Path)):
clip = read_audio(item)
else:
clip = item.detach().cpu().numpy() if isinstance(item, torch.Tensor) else np.asarray(item)
if clip.ndim != 1:
raise ValueError(f"in-memory audio must be 1-D mono 16 kHz samples, got shape {clip.shape}")
clip = clip.astype(np.float32, copy=False)
if len(clip) < MIN_DECODE_SAMPLES:
clip = np.pad(clip, (0, MIN_DECODE_SAMPLES - len(clip)))
return clip
def transcribe(model, paths_or_audio, batch_size: int = 16, max_batch_seconds: float = MAX_BATCH_SECONDS):
"""Greedy TDT transcripts (punctuated, cased), decoded as the reported evaluation decoded.
paths_or_audio: one item or a list of items, each a path to a 16 kHz audio file or a 1-D
float array/tensor of 16 kHz mono samples in [-1, 1]. Returns a str for a single item and a
list of str (in input order) for a list.
Decoding: preprocessor -> encoder -> model.decoding.rnnt_decoder_predictions_tensor with the
model's own decoding config (greedy_batch TDT, durations 0-4, max 10 symbols per frame) in
strict FP32 (autocast off, TF32 off for matmuls and cuDNN), dither 0; on CUDA in NeMo's
no_while_loops CUDA-graph mode, as evaluated. Inputs are batched longest first, zero-padded,
at most batch_size items and max_batch_seconds of audio per batch.
Long audio: each input is decoded in a single pass with full attention; nothing is chunked.
Memory grows roughly quadratically with input length. The model was trained on 1-30 s
utterances; split long recordings (ideally at pauses) into segments of about 30 s or less.
"""
single = not isinstance(paths_or_audio, (list, tuple))
items = [paths_or_audio] if single else list(paths_or_audio)
if not items:
return []
params = list(model.parameters())
if any(p.dtype != torch.float32 for p in params):
raise ValueError("decoding is specified in FP32; load() returns an FP32 model")
device = params[0].device
if device.type == "cuda":
_pin_decoder_graphs(model)
clips = [_as_clip(item) for item in items]
order = sorted(range(len(clips)), key=lambda i: (-len(clips[i]), i))
batches, current, seconds = [], [], 0.0
for i in order:
duration = len(clips[i]) / SAMPLE_RATE
if current and (len(current) >= batch_size or seconds + duration > max_batch_seconds):
batches.append(current)
current, seconds = [], 0.0
current.append(i)
seconds += duration
batches.append(current)
texts: list[str | None] = [None] * len(clips)
with _strict_fp32(device), _inference_settings(model):
for idx in batches:
lengths = torch.tensor([len(clips[i]) for i in idx], dtype=torch.long)
audio = torch.zeros(len(idx), int(lengths.max()), dtype=torch.float32)
for row, i in enumerate(idx):
audio[row, :len(clips[i])] = torch.from_numpy(clips[i])
enc, enc_len = model.forward(input_signal=audio.to(device), input_signal_length=lengths.to(device))
hyps = model.decoding.rnnt_decoder_predictions_tensor(
encoder_output=enc, encoded_lengths=enc_len, return_hypotheses=False)
if isinstance(hyps, tuple):
hyps = hyps[0]
for i, hyp in zip(idx, hyps):
texts[i] = hyp.text if hasattr(hyp, "text") else str(hyp)
return texts[0] if single else texts
def main(argv: list[str]) -> None:
if not argv or argv[0] in ("-h", "--help"):
sys.exit("usage: python load_binary.py clip.wav [clip2.wav ...] (16 kHz audio)")
from nemo.utils import logging as nemo_logging
nemo_logging.setLevel(nemo_logging.ERROR)
device = "cuda" if torch.cuda.is_available() else "cpu"
model = load(Path(__file__).resolve().parent, device=device)
for name, text in zip(argv, transcribe(model, argv)):
print(text if len(argv) == 1 else f"{name}\t{text}")
if __name__ == "__main__":
main(sys.argv[1:])
|