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:])