voice_audio_multi/tools/community_models/convert_orukeet.py
Fabio c36510c21e
Some checks failed
Linux Build / Linux cpu (push) Has been cancelled
Linux Build / Linux cpu-clang (push) Has been cancelled
Linux Build / Linux vulkan (push) Has been cancelled
mac-build / macOS CPU (push) Has been cancelled
Nix Build / Nix (cpu) (push) Has been cancelled
Nix Build / Nix (vulkan) (push) Has been cancelled
windows-build / Windows CPU (push) Has been cancelled
Initial import: audio.cpp with Orange Pi documentation
2026-11-04 19:05:22 +01:00

503 lines
22 KiB
Python

#!/usr/bin/env python3
"""Stage Orukeet r3 weights for audio.cpp (Parakeet TDT family).
This converter reads the official Oruk ``orukeet-v0.1.0.nemo`` checkpoint, not
a GGUF round-trip, and stages an HF-layout package that the existing
``parakeet_tdt`` audio.cpp loader consumes unchanged:
model.safetensors HF/audio.cpp tensor names (``encoder.*``/``decoder.*``/...)
config.json copied from the stock Parakeet-TDT-0.6B-v3 install
processor_config.json copied from the stock install, verified against .nemo
tokenizer.json copied from the stock install (tokenizer is unchanged)
tensor_manifest.json source/destination names, shapes, and dtypes
provenance.json source revision, hashes, and license information
Architecture verdict: Orukeet r3 is a drop-in weight replacement for
``nvidia/parakeet-tdt-0.6b-v3``. It keeps 24 FastConformer layers, 1024 hidden,
9-tap temporal depthwise convolutions, 8x subsampling, 128 mel bins, a 2x640
LSTM prediction network, 8192-token vocabulary plus blank, and TDT durations
[0..4]. The 12,288 fitted Gabor kernels are materialized as ordinary Conv1d
weights, so no new operators, tensor shapes, or tokenizer changes are needed.
Evidence: the r3 export audit (651/651 parameter tensors changed, 74 fixed
buffers, tokenizer assets byte-equal, tensor shapes/dtypes equal) and the ONNX
export notes (same frontend, subsampling, vocabulary, predictor, durations).
The NeMo-side key inventory below was derived from that audit; the HF-side
names are the contract in ``src/community_models/parakeet_tdt/weights.cpp``.
The NeMo ``joint.pred`` projection (640->640) maps to audio.cpp's
``decoder.decoder_projector``; ``joint.enc`` maps to ``encoder_projector`` and
``joint.joint_net.{1,2}`` (index 2 when joint dropout is enabled, else 1) maps
to ``joint.head``. Batch-norm running statistics are fixed buffers in the
checkpoint and are staged as ``conv.norm.running_mean/var`` because the
audio.cpp loader folds them into the depthwise weights at load time.
Sidecars are intentionally NOT regenerated: the audit certifies the tokenizer
is unchanged and this script verifies the preprocessor section of the .nemo
config against the reference install before staging. Pass
``--reference-dir`` pointing at an existing
``models/parakeet-tdt-0.6b-v3`` directory (``model_manager_v2.py install
parakeet_tdt``).
This script never downloads anything. It verifies the input hash against the
pinned r3 checkpoint and refuses to convert anything else unless
``--skip-hash-check`` is given.
Dependencies are intentionally small and work without installing NeMo:
uv run --python 3.12 --with torch --with pyyaml --with safetensors \
--with sentencepiece --with numpy \
tools/community_models/convert_orukeet.py \
--checkpoint path/to/orukeet-v0.1.0.nemo \
--converter build/windows-cuda-release/bin/audiocpp_gguf.exe \
--gguf-output models/Orukeet-GGUF/orukeet-q8_0.gguf \
--type q8_0
To inspect a checkpoint without converting:
uv run --python 3.12 --with torch --with pyyaml --with sentencepiece \
tools/community_models/convert_orukeet.py \
--checkpoint path/to/orukeet-v0.1.0.nemo --dump-keys
"""
from __future__ import annotations
import argparse
import hashlib
import json
import shutil
import subprocess
import tarfile
import tempfile
from pathlib import Path
from typing import Any
SOURCE_REPO = "oruk/orukeet"
SOURCE_REVISION = "555136b50265a132d4cea0d35560c26fc4f657ab"
SOURCE_FILE = "orukeet-v0.1.0.nemo"
SOURCE_SHA256 = "031c8ddab4845aeced904a7cde8e8aa57993b2e344716cf83a545b079c473b56"
SOURCE_LICENSE = "CC-BY-SA-4.0 (Orukeet r3 weights and fitted kernels)"
BASE_LICENSE = "CC-BY-4.0 (NVIDIA parakeet-tdt-0.6b-v3 foundation weights)"
SOURCE_LICENSE_URL = "https://huggingface.co/oruk/orukeet/blob/main/LICENSE-WEIGHTS"
ATTRIBUTION_URL = "https://huggingface.co/oruk/orukeet/blob/main/NOTICE.md"
MAPPING_REFERENCE = "oruk/orukeet evidence/librispeech-ft-20260908/r3/export-audit.json"
MAPPING_REFERENCE_COMMIT = "e0c97b367f31a49ae8f43e6d5ec48b6300d1126b"
EXPECTED_LAYERS = 24
EXPECTED_HIDDEN = 1024
EXPECTED_VOCAB = 8193
EXPECTED_BLANK = 8192
EXPECTED_DURATIONS = [0, 1, 2, 3, 4]
EXPECTED_JOINT_OUT = EXPECTED_VOCAB + len(EXPECTED_DURATIONS)
SUBSAMPLING_TABLE = [
("conv.0", "layers.0"),
("conv.2", "layers.2"),
("conv.3", "layers.3"),
("conv.5", "layers.5"),
("conv.6", "layers.6"),
]
# NeMo conformer-layer suffix -> HF/audio.cpp suffix, relative to the layer
# prefix. Batch-norm statistics ride along as fixed buffers.
LAYER_TABLE = [
("norm_feed_forward1.weight", "norm_feed_forward1.weight"),
("norm_feed_forward1.bias", "norm_feed_forward1.bias"),
("feed_forward1.linear1.weight", "feed_forward1.linear1.weight"),
("feed_forward1.linear2.weight", "feed_forward1.linear2.weight"),
("norm_self_att.weight", "norm_self_att.weight"),
("norm_self_att.bias", "norm_self_att.bias"),
("self_attn.linear_q.weight", "self_attn.q_proj.weight"),
("self_attn.linear_k.weight", "self_attn.k_proj.weight"),
("self_attn.linear_v.weight", "self_attn.v_proj.weight"),
("self_attn.linear_out.weight", "self_attn.o_proj.weight"),
("self_attn.linear_pos.weight", "self_attn.relative_k_proj.weight"),
("self_attn.pos_bias_u", "self_attn.bias_u"),
("self_attn.pos_bias_v", "self_attn.bias_v"),
("norm_conv.weight", "norm_conv.weight"),
("norm_conv.bias", "norm_conv.bias"),
("conv.pointwise_conv1.weight", "conv.pointwise_conv1.weight"),
("conv.depthwise_conv.weight", "conv.depthwise_conv.weight"),
("conv.batch_norm.weight", "conv.norm.weight"),
("conv.batch_norm.bias", "conv.norm.bias"),
("conv.batch_norm.running_mean", "conv.norm.running_mean"),
("conv.batch_norm.running_var", "conv.norm.running_var"),
("conv.pointwise_conv2.weight", "conv.pointwise_conv2.weight"),
("norm_feed_forward2.weight", "norm_feed_forward2.weight"),
("norm_feed_forward2.bias", "norm_feed_forward2.bias"),
("feed_forward2.linear1.weight", "feed_forward2.linear1.weight"),
("feed_forward2.linear2.weight", "feed_forward2.linear2.weight"),
("norm_out.weight", "norm_out.weight"),
("norm_out.bias", "norm_out.bias"),
]
SKIP_PREFIXES = ("preprocessor.",)
SKIP_SUFFIXES = (".num_batches_tracked",)
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def load_checkpoint(source: Path) -> tuple[dict[str, Any], dict[str, Any], list[str]]:
"""Return the NeMo YAML config, state dict, and .nemo member names."""
import torch
import yaml
if source.is_dir():
config_path = source / "model_config.yaml"
checkpoint_path = source / "model_weights.ckpt"
if not config_path.exists() or not checkpoint_path.exists():
raise SystemExit(
f"checkpoint directory needs model_config.yaml and model_weights.ckpt: {source}"
)
config = yaml.safe_load(config_path.read_text(encoding="utf-8"))
state = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
members = [config_path.name, checkpoint_path.name]
return config, state, members
if not source.exists():
raise SystemExit(f"checkpoint not found: {source}")
def member(archive: tarfile.TarFile, name: str):
for candidate in (name, f"./{name}"):
try:
found = archive.extractfile(candidate)
except KeyError:
continue
if found is not None:
return found
return None
with tarfile.open(source, mode="r:*") as archive:
names = archive.getnames()
config_member = member(archive, "model_config.yaml")
weights_member = member(archive, "model_weights.ckpt")
if config_member is None or weights_member is None:
raise SystemExit(
f"NeMo archive is missing model_config.yaml or model_weights.ckpt: {source}"
)
config_text = config_member.read().decode("utf-8")
weights = weights_member.read()
with tempfile.NamedTemporaryFile(suffix=".ckpt", delete=False) as handle:
handle.write(weights)
checkpoint_path = Path(handle.name)
config = yaml.safe_load(config_text)
try:
import torch
state = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
finally:
checkpoint_path.unlink(missing_ok=True)
return config, state, names
def need(config: dict[str, Any], dotted: str) -> Any:
node: Any = config
for part in dotted.split("."):
if not isinstance(node, dict) or part not in node:
top = sorted(config.keys()) if isinstance(config, dict) else []
raise ValueError(
f"NeMo config has no '{dotted}' (top-level keys: {top})"
)
node = node[part]
return node
def assert_model_shape(config: dict[str, Any]) -> None:
checks = {
"encoder.n_layers": (need(config, "encoder.n_layers"), EXPECTED_LAYERS),
"encoder.d_model": (need(config, "encoder.d_model"), EXPECTED_HIDDEN),
}
mismatches = [
f"{name}={actual!r}, expected {expected!r}"
for name, (actual, expected) in checks.items()
if actual != expected
]
if mismatches:
raise ValueError(
"not the expected Parakeet-TDT-0.6B-v3 architecture: " + "; ".join(mismatches)
)
def verify_preprocessor(config: dict[str, Any], reference: Path) -> None:
"""Fail unless the .nemo preprocessor matches the reference install."""
ref = json.loads((reference / "processor_config.json").read_text(encoding="utf-8"))
feat = ref.get("feature_extractor", ref)
pre = config.get("preprocessor", {})
expected = {
"sampling_rate": int(feat.get("sampling_rate", 16000)),
"features": int(feat.get("feature_size", 128)),
"n_fft": int(feat.get("n_fft", 512)),
}
actual = {
"sampling_rate": int(pre.get("sample_rate", -1)),
"features": int(pre.get("features", -1)),
"n_fft": int(pre.get("n_fft", -1)),
}
if actual != expected:
raise ValueError(
f".nemo preprocessor {actual} does not match reference {expected}; "
"refusing to reuse the stock sidecars"
)
def verify_tokenizer_vocab(checkpoint: Path, expected: int) -> None:
"""Fail unless the .nemo sentencepiece model has the expected piece count.
Note: the ``*_vocab.txt`` member is a training word list, not the BPE
vocabulary, so the check runs against ``*_tokenizer.model`` instead.
"""
import sentencepiece as spm
if checkpoint.is_dir():
candidates = list(checkpoint.glob("*_tokenizer.model"))
if not candidates:
raise SystemExit(f"checkpoint directory has no *_tokenizer.model: {checkpoint}")
processor = spm.SentencePieceProcessor(model_file=str(candidates[0]))
else:
with tarfile.open(checkpoint, mode="r:*") as archive:
names = [n for n in archive.getnames() if n.endswith("_tokenizer.model")]
if not names:
raise SystemExit(f"NeMo archive has no *_tokenizer.model: {checkpoint}")
member = archive.extractfile(names[0])
assert member is not None
blob = member.read()
with tempfile.NamedTemporaryFile(suffix=".model", delete=False) as handle:
handle.write(blob)
model_path = Path(handle.name)
try:
processor = spm.SentencePieceProcessor(model_file=str(model_path))
finally:
model_path.unlink(missing_ok=True)
pieces = processor.get_piece_size()
if pieces != expected:
raise ValueError(
f".nemo tokenizer has {pieces} pieces, expected {expected}; "
"refusing to reuse the stock tokenizer"
)
def dump_keys(checkpoint: Path) -> None:
_, state, _ = load_checkpoint(checkpoint)
for key in sorted(state.keys()):
tensor = state[key]
shape = list(tensor.shape) if hasattr(tensor, "shape") else type(tensor).__name__
print(f"{key} {shape}")
def convert(checkpoint: Path, reference: Path, output_dir: Path, source_hash: str) -> None:
import torch
from safetensors.torch import save_file
for name in ("config.json", "processor_config.json", "tokenizer.json"):
if not (reference / name).exists():
raise SystemExit(f"reference install is missing {name}: {reference}")
ref_config = json.loads((reference / "config.json").read_text(encoding="utf-8"))
if int(ref_config.get("vocab_size", -1)) != EXPECTED_VOCAB:
raise SystemExit(
f"reference vocab_size={ref_config.get('vocab_size')}, expected {EXPECTED_VOCAB}"
)
config, state, _ = load_checkpoint(checkpoint)
if not isinstance(state, dict) or not state:
raise ValueError("checkpoint did not contain a non-empty state dictionary")
assert_model_shape(config)
verify_preprocessor(config, reference)
verify_tokenizer_vocab(checkpoint, 8192)
output_dir.mkdir(parents=True, exist_ok=True)
out: dict[str, torch.Tensor] = {}
manifest: list[dict[str, Any]] = []
used: set[str] = set()
def emit(src: str, dst: str, shape: list[int] | None = None) -> None:
if src not in state:
raise KeyError(f"missing expected tensor: {src}")
if dst in out:
raise KeyError(f"duplicate destination tensor: {dst}")
tensor = state[src]
if not isinstance(tensor, torch.Tensor):
raise TypeError(f"expected tensor for {src}, got {type(tensor).__name__}")
tensor = tensor.detach().cpu().contiguous()
if shape is not None and list(tensor.shape) != shape:
raise ValueError(
f"shape mismatch for {src}: got {list(tensor.shape)}, expected {shape}"
)
out[dst] = tensor
used.add(src)
manifest.append(
{"source": src, "destination": dst, "shape": list(tensor.shape),
"dtype": str(tensor.dtype)}
)
for src_mid, dst_mid in SUBSAMPLING_TABLE:
emit(f"encoder.pre_encode.{src_mid}.weight",
f"encoder.subsampling.{dst_mid}.weight")
emit(f"encoder.pre_encode.{src_mid}.bias",
f"encoder.subsampling.{dst_mid}.bias")
emit("encoder.pre_encode.out.weight", "encoder.subsampling.linear.weight")
emit("encoder.pre_encode.out.bias", "encoder.subsampling.linear.bias")
for i in range(EXPECTED_LAYERS):
for src_suffix, dst_suffix in LAYER_TABLE:
emit(f"encoder.layers.{i}.{src_suffix}", f"encoder.layers.{i}.{dst_suffix}")
emit("decoder.prediction.embed.weight", "decoder.embedding.weight",
[EXPECTED_VOCAB, 640])
for layer in range(2):
for kind in ("weight_ih", "weight_hh", "bias_ih", "bias_hh"):
emit(f"decoder.prediction.dec_rnn.lstm.{kind}_l{layer}",
f"decoder.lstm.{kind}_l{layer}", [2560, 640] if "weight" in kind else [2560])
# NeMo's joint prediction projection is filed under the audio.cpp decoder.
emit("joint.pred.weight", "decoder.decoder_projector.weight", [640, 640])
emit("joint.pred.bias", "decoder.decoder_projector.bias", [640])
emit("joint.enc.weight", "encoder_projector.weight", [640, EXPECTED_HIDDEN])
emit("joint.enc.bias", "encoder_projector.bias", [640])
joint_head = None
for candidate in ("joint.joint_net.2", "joint.joint_net.1"):
if f"{candidate}.weight" in state:
joint_head = candidate
break
if joint_head is None:
raise KeyError("missing expected tensor: joint.joint_net.{1,2}.weight")
emit(f"{joint_head}.weight", "joint.head.weight", [EXPECTED_JOINT_OUT, 640])
emit(f"{joint_head}.bias", "joint.head.bias", [EXPECTED_JOINT_OUT])
unexpected = [
key for key in set(state) - used
if not key.startswith(SKIP_PREFIXES) and not key.endswith(SKIP_SUFFIXES)
]
if unexpected:
raise ValueError(
f"{len(unexpected)} unmapped state_dict tensors, e.g. {sorted(unexpected)[:8]}"
)
save_file(out, str(output_dir / "model.safetensors"), metadata={
"format": "orukeet-r3-audiocpp-staging",
"tensor_namespace": "encoder/decoder/joint",
"source_repo": SOURCE_REPO,
"source_revision": SOURCE_REVISION,
})
for name in ("config.json", "processor_config.json", "tokenizer.json"):
shutil.copyfile(reference / name, output_dir / name)
(output_dir / "tensor_manifest.json").write_text(json.dumps({
"source": SOURCE_REPO,
"revision": SOURCE_REVISION,
"source_sha256": source_hash,
"joint_head_source": joint_head,
"source_tensor_count": len(state),
"emitted_tensor_count": len(out),
"skipped_tensor_count": len(state) - len(out),
"tensors": manifest,
}, indent=2) + "\n", encoding="utf-8")
(output_dir / "provenance.json").write_text(json.dumps({
"source_repo": SOURCE_REPO,
"source_revision": SOURCE_REVISION,
"source_file": SOURCE_FILE,
"source_license": SOURCE_LICENSE,
"base_license": BASE_LICENSE,
"source_license_url": SOURCE_LICENSE_URL,
"attribution": ATTRIBUTION_URL,
"source_sha256": source_hash,
"mapping_reference": MAPPING_REFERENCE,
"mapping_reference_commit": MAPPING_REFERENCE_COMMIT,
"tensor_namespace": "encoder/decoder/joint",
"sidecars": "reused from stock parakeet-tdt-0.6b-v3 install; "
"tokenizer certified unchanged by the r3 export audit",
}, indent=2) + "\n", encoding="utf-8")
print(f"source tensors: {len(state)}")
print(f"emitted tensors: {len(out)}")
print(f"skipped tensors: {len(state) - len(out)} (preprocessor + counters)")
print(f"wrote staging package: {output_dir}")
def package_with_audiocpp(args: argparse.Namespace, staging: Path) -> None:
if args.converter is None or args.gguf_output is None:
return
converter = args.converter.resolve()
if not converter.exists():
raise SystemExit(f"audiocpp_gguf not found: {converter}")
spec = Path(__file__).resolve().parents[2] / "model_specs" / "parakeet_tdt.json"
if not spec.exists():
raise SystemExit(f"model spec not found: {spec}")
output = args.gguf_output.resolve()
output.parent.mkdir(parents=True, exist_ok=True)
command = [
str(converter),
"--input", str(staging / "model.safetensors"),
"--root", str(staging),
"--family", "parakeet_tdt",
"--model-spec", str(spec),
"--type", args.type,
"--output", str(output),
"--overwrite",
]
print("+", " ".join(command))
subprocess.run(command, check=True)
def main() -> int:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--checkpoint", type=Path, required=True,
help="official .nemo file or extracted checkpoint directory")
parser.add_argument("--reference-dir", type=Path, default=None,
help="stock parakeet-tdt-0.6b-v3 install "
"(default: <repo>/models/parakeet-tdt-0.6b-v3)")
parser.add_argument("--output-dir", type=Path, default=None,
help="keep the staging package here instead of a temp dir")
parser.add_argument("--converter", type=Path, default=None,
help="audiocpp_gguf executable for the GGUF step")
parser.add_argument("--gguf-output", type=Path, default=None,
help="final GGUF output path (requires --converter)")
parser.add_argument("--type", default="orig",
choices=["orig", "f32", "f16", "bf16", "q8_0"],
help="GGUF storage type; use orig for the F32 gate")
parser.add_argument("--dump-keys", action="store_true",
help="list checkpoint keys and shapes, then exit")
parser.add_argument("--skip-hash-check", action="store_true",
help="allow a checkpoint whose SHA-256 is not the pinned r3")
args = parser.parse_args()
checkpoint = args.checkpoint.resolve()
if args.dump_keys:
dump_keys(checkpoint)
return 0
if args.reference_dir is None:
args.reference_dir = Path(__file__).resolve().parents[2] / "models" / "parakeet-tdt-0.6b-v3"
if (args.gguf_output is not None) != (args.converter is not None):
raise SystemExit("--converter and --gguf-output must be given together")
if args.output_dir is None and args.converter is None:
raise SystemExit("nothing to keep: give --output-dir, or --converter with --gguf-output")
source_hash = sha256(checkpoint) if checkpoint.is_file() else None
if source_hash is None:
import torch
sibling = next(checkpoint.glob("*.ckpt"), None)
source_hash = sha256(sibling) if sibling else "extracted-directory"
if not args.skip_hash_check and source_hash != SOURCE_SHA256:
raise SystemExit(
f"checkpoint SHA-256 {source_hash} is not the pinned r3 {SOURCE_SHA256}; "
"pass --skip-hash-check to override"
)
if args.output_dir is not None:
staging = args.output_dir.resolve()
convert(checkpoint, args.reference_dir.resolve(), staging, source_hash)
else:
with tempfile.TemporaryDirectory(prefix="orukeet-convert-") as tmp:
staging = Path(tmp) / "staging"
convert(checkpoint, args.reference_dir.resolve(), staging, source_hash)
package_with_audiocpp(args, staging)
return 0
package_with_audiocpp(args, staging)
return 0
if __name__ == "__main__":
raise SystemExit(main())