#!/usr/bin/env python3 # Convert Parakeet TDT model from NeMo format to ggml format # # Usage: python convert-parakeet-to-ggml.py --model parakeet-model.nemo --output-dir output-dir [--use-f32] # # The NeMo file is a tar archive containing: # - model_weights.ckpt (PyTorch checkpoint) # - model_config.yaml (model configuration) # - tokenizer files # # This script extracts the NeMo archive, loads the model weights and configuration, # and saves them in ggml format compatible with whisper.cpp. # import torch import argparse import io import os import sys import struct import tarfile import tempfile import shutil import yaml import numpy as np from pathlib import Path from typing import Optional def hz_to_mel(freq): return 2595.0 * np.log10(1.0 + freq / 700.0) def mel_to_hz(mel): return 700.0 * (10.0**(mel / 2595.0) - 1.0) def extract_nemo_archive(nemo_path, extract_dir): print(f"Extracting {nemo_path} to {extract_dir}") with tarfile.open(nemo_path, 'r') as tar: tar.extractall(path=extract_dir) print("Extraction complete") def load_model_config(config_path): with open(config_path, 'r', encoding='utf-8') as f: config = yaml.safe_load(f) return config def load_tokenizer(extract_dir, config): tokenizer_model_path = None tokenizer_vocab_path = None for file in os.listdir(extract_dir): if file.endswith('_tokenizer.model'): tokenizer_model_path = os.path.join(extract_dir, file) elif file.endswith('tokenizer.vocab'): tokenizer_vocab_path = os.path.join(extract_dir, file) if not tokenizer_model_path: raise FileNotFoundError("Tokenizer model file not found") if not tokenizer_vocab_path: raise FileNotFoundError("Tokenizer vocab file not found") tokens = {} with open(tokenizer_vocab_path, 'r', encoding='utf-8') as f: for idx, line in enumerate(f): parts = line.strip().split('\t') if len(parts) >= 1: token = parts[0] tokens[token.encode('utf-8')] = idx print(f"Loaded {len(tokens)} tokens from {os.path.basename(tokenizer_vocab_path)}") if len(tokens) != 8192: print(f"WARNING: Expected 8192 tokens, got {len(tokens)}") return tokens def write_tensor(fout, name, data, use_f16=True, force_f32=False): if 'pre_encode.conv' in name and 'bias' in name and len(data.shape) == 1: data = data.reshape(1, -1, 1, 1) print(f" Reshaped conv bias {name} to {data.shape}") n_dims = len(data.shape) ftype = 1 if use_f16 and not force_f32 else 0 if force_f32: data = data.astype(np.float32) elif use_f16: if n_dims < 2 or 'bias' in name or 'norm' in name or \ ('pre_encode.conv' in name and n_dims == 4) or \ 'depthwise_conv.weight' in name: data = data.astype(np.float32) ftype = 0 else: data = data.astype(np.float16) else: data = data.astype(np.float32) dims_reversed = [data.shape[n_dims - 1 - i] for i in range(n_dims)] print(f"Processing: {name} {list(data.shape)}, dtype: {data.dtype}, n_dims: {n_dims}, reversed: {dims_reversed}") name_bytes = name.encode('utf-8') fout.write(struct.pack("iii", n_dims, len(name_bytes), ftype)) for i in range(n_dims): fout.write(struct.pack("i", data.shape[n_dims - 1 - i])) fout.write(name_bytes) data.tofile(fout) def convert_parakeet_to_ggml(nemo_path, output_dir, use_f16=True, out_name=None): nemo_path = Path(nemo_path) output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) # Create temporary directory for extraction with tempfile.TemporaryDirectory() as temp_dir: extract_nemo_archive(nemo_path, temp_dir) config_path = os.path.join(temp_dir, 'model_config.yaml') config = load_model_config(config_path) print("Model configuration:") print(f" Sample rate: {config['sample_rate']}") print(f" Encoder layers: {config['encoder']['n_layers']}") print(f" Encoder d_model: {config['encoder']['d_model']}") print(f" Mel features: {config['preprocessor']['features']}") weights_path = os.path.join(temp_dir, 'model_weights.ckpt') print(f"\nLoading model weights from {weights_path}") checkpoint = torch.load(weights_path, map_location='cpu') # Extract state dict if 'state_dict' in checkpoint: state_dict = checkpoint['state_dict'] else: state_dict = checkpoint print(f"Loaded {len(state_dict)} tensors") # Load tokenizer print("\nLoading tokenizer...") tokens = load_tokenizer(temp_dir, config) print(f"Loaded {len(tokens)} tokens") # Prepare hyperparameters for the Parakeet ggml format. hparams = { 'n_audio_ctx': 5000, 'n_audio_state': config['encoder']['d_model'], 'n_audio_head': config['encoder']['n_heads'], 'n_audio_layer': config['encoder']['n_layers'], 'n_mels': config['preprocessor']['features'], 'n_fft': config['preprocessor']['n_fft'], 'subsampling_factor': config['encoder']['subsampling_factor'], 'n_subsampling_channels': config['encoder']['subsampling_conv_channels'], 'n_conv_kernel': config['encoder']['conv_kernel_size'], 'n_pred_dim': config['decoder']['prednet']['pred_hidden'], 'n_pred_layers': config['decoder']['prednet']['pred_rnn_layers'], 'n_vocab': config['decoder']['vocab_size'], 'n_tdt_durations': config['model_defaults']['num_tdt_durations'], 'n_max_tokens': config['decoding']['greedy']['max_symbols'], } print("\nGGML hyperparameters:") for key, value in hparams.items(): print(f" {key}: {value}") # Create output file if out_name: fname_out = output_dir / out_name else: fname_out = output_dir / ("ggml-model-f32.bin" if not use_f16 else "ggml-model.bin") print(f"\nWriting to {fname_out}") with open(fname_out, 'wb') as fout: # Write magic number fout.write(struct.pack("i", 0x67676d6c)) # 'ggml' in hex # Write hyperparameters fout.write(struct.pack("i", hparams['n_vocab'])) fout.write(struct.pack("i", hparams['n_audio_ctx'])) fout.write(struct.pack("i", hparams['n_audio_state'])) fout.write(struct.pack("i", hparams['n_audio_head'])) fout.write(struct.pack("i", hparams['n_audio_layer'])) fout.write(struct.pack("i", hparams['n_mels'])) fout.write(struct.pack("i", 1 if use_f16 else 0)) fout.write(struct.pack("i", hparams['n_fft'])) fout.write(struct.pack("i", hparams['subsampling_factor'])) fout.write(struct.pack("i", hparams['n_subsampling_channels'])) fout.write(struct.pack("i", hparams['n_conv_kernel'])) fout.write(struct.pack("i", hparams['n_pred_dim'])) fout.write(struct.pack("i", hparams['n_pred_layers'])) fout.write(struct.pack("i", hparams['n_tdt_durations'])) fout.write(struct.pack("i", hparams['n_max_tokens'])) # Extract mel filterbank from model fb_key = None for key in state_dict.keys(): if 'featurizer.fb' in key or 'filterbank' in key.lower(): fb_key = key break if not fb_key: print("\nERROR: Mel filterbank not found in model!") print("Expected tensor with 'featurizer.fb' or 'filterbank' in name") print("\nAvailable preprocessor tensors:") for key in sorted(state_dict.keys()): if 'preprocessor' in key or 'featurizer' in key: print(f" {key}: {state_dict[key].shape}") raise ValueError("Mel filterbank tensor not found in model") print(f"\nUsing model's mel filterbank from: {fb_key}") mel_filters = state_dict[fb_key].squeeze().numpy().astype(np.float32) print(f" Filterbank shape: {mel_filters.shape}") print(f" Filterbank min/max values: {mel_filters.min():.6f} / {mel_filters.max():.6f}") print(f" Filterbank non-zero elements: {np.count_nonzero(mel_filters)} / {mel_filters.size}") print(f" First row sum: {mel_filters[0].sum():.6f}") if len(mel_filters.shape) != 2: raise ValueError(f"Expected 2D filterbank, got shape {mel_filters.shape}") n_mels, n_freqs = mel_filters.shape fout.write(struct.pack("i", n_mels)) # n_mel fout.write(struct.pack("i", n_freqs)) # n_fb (frequency bins) # Write mel filterbank for i in range(n_mels): for j in range(n_freqs): fout.write(struct.pack("f", mel_filters[i, j])) # Extract window function from model window_key = None for key in state_dict.keys(): if 'featurizer.window' in key or 'preproc' in key and 'window' in key: window_key = key break if not window_key: print("\nERROR: Window function not found in model!") print("Expected tensor with 'featurizer.window' in name") raise ValueError("Window function tensor not found in model") print(f"\nUsing model's window function from: {window_key}") window = state_dict[window_key].squeeze().numpy().astype(np.float32) print(f" Window shape: {window.shape}") print(f" Window min/max values: {window.min():.6f} / {window.max():.6f}") print(f" Window non-zero elements: {np.count_nonzero(window)} / {window.size}") print(f" Window sum: {window.sum():.6f}") if len(window.shape) != 1: raise ValueError(f"Expected 1D window, got shape {window.shape}") n_window = window.shape[0] fout.write(struct.pack("i", n_window)) # Write window function for i in range(n_window): fout.write(struct.pack("f", window[i])) # Write TDT durations tdt_durations = config['model_defaults']['tdt_durations'] if len(tdt_durations) != hparams['n_tdt_durations']: raise ValueError(f"TDT durations count mismatch: {len(tdt_durations)} vs {hparams['n_tdt_durations']}") for duration in tdt_durations: fout.write(struct.pack("I", duration)) fout.write(struct.pack("i", len(tokens))) for token_bytes, idx in sorted(tokens.items(), key=lambda x: x[1]): fout.write(struct.pack("i", len(token_bytes))) fout.write(token_bytes) # Pre-collect prediction LSTM input-hidden biases so they can be # folded into the hidden-hidden bias during the main write loop. lstm_prefix = 'decoder.prediction.dec_rnn.lstm' pred_bias_ih = {} for key, t in state_dict.items(): if f'{lstm_prefix}.bias_ih_l' in key: layer_idx = int(key.rsplit('bias_ih_l', 1)[1]) pred_bias_ih[layer_idx] = t.squeeze().numpy().astype(np.float32) print("\nConverting model weights...") for name, tensor in state_dict.items(): # Skip the filterbank and window - already written in preprocessing section if name == fb_key: continue if name == window_key: continue # bias_ih is folded into bias_hh below; skip writing it separately if f'{lstm_prefix}.bias_ih_l' in name: continue # Don't squeeze Conv2d weights - they need to preserve all 4 dimensions if 'conv' in name and 'weight' in name and len(tensor.shape) == 4: data = tensor.numpy() else: data = tensor.squeeze().numpy() # For prediction LSTM weights/biases: # Fold bias_ih into bias_hh (bias_ih already skipped above). # Reorder gates (input, forget, cell, output) from PyTorch layout # [i, f, g, o] to [i, f, o, g] so the three sigmoid-gated outputs # (i, f, o) are contiguous. if name.startswith(f'{lstm_prefix}.'): if f'{lstm_prefix}.bias_hh_l' in name: layer_idx = int(name.rsplit('bias_hh_l', 1)[1]) data = data.astype(np.float32) + pred_bias_ih[layer_idx] name = name.replace('bias_hh_l', 'bias_h_l') h = data.shape[0] // 4 data = np.concatenate([data[:h], data[h:2*h], data[3*h:], data[2*h:3*h]], axis=0) write_tensor(fout, name, data, use_f16=use_f16) print(f"\nConversion complete!") print(f"Output file: {fname_out}") print(f"File size: {fname_out.stat().st_size / (1024**2):.2f} MB") if __name__ == '__main__': parser = argparse.ArgumentParser( description='Convert Parakeet TDT model from NeMo format to ggml format' ) parser.add_argument('--model', type=str, required=True, help='Path to Parakeet .nemo model file') parser.add_argument('--out-dir', type=str, required=True, help='Directory to write ggml model file') parser.add_argument('--use-f32', action='store_true', default=False, help='Use f32 instead of f16 (default: f16)') parser.add_argument('--out-name', type=str, default=None, help='Output file name (default: ggml-model.bin or ggml-model-f32.bin)') args = parser.parse_args() if not os.path.exists(args.model): print(f"Error: {args.model} not found") sys.exit(1) use_f16 = not args.use_f32 convert_parakeet_to_ggml(args.model, args.out_dir, use_f16, args.out_name)