#!/usr/bin/env python3 """Cossim debug : C++ omnivoice-tts vs Python OmniVoice on voice design. Inputs (relative to CWD = tests/) : ../examples/prompt.txt target text fed to both pipelines Both sides run with : instruct=male, language=English, seed=42, F32 weights, no pre or post process. Defaults match : num_step=32, guidance_scale=2.0, t_shift=0.1, layer_penalty_factor=5.0, position_temperature=5.0, class_temperature=0.0. Dumps land in cpp/ (C++) and python/ (Python). The script compares each matching .bin pair via cosine similarity over the f32 payload, plus exact match rate for tensors that originated as int (mg-tokens). All paths are relative, no absolute paths anywhere. """ import argparse import os import struct import subprocess import sys # Strict F32 matmul on both sides. NVIDIA_TF32_OVERRIDE=0 forces full FP32 # mantissa in cuBLAS for both PyTorch and the C++ child via inheritance. # Must be set BEFORE torch imports so the cuBLAS handle reads it on init. os.environ["NVIDIA_TF32_OVERRIDE"] = "0" import numpy as np import soundfile as sf import torch # Belt and suspenders : disable PyTorch's own TF32 toggles too. Some code # paths bypass NVIDIA_TF32_OVERRIDE through cudnn or torch internal flags. torch.backends.cuda.matmul.allow_tf32 = False torch.backends.cudnn.allow_tf32 = False torch.backends.cuda.matmul.allow_fp16_reduced_precision_reduction = False torch.backends.cuda.matmul.allow_bf16_reduced_precision_reduction = False torch.set_float32_matmul_precision("highest") from omnivoice import OmniVoice from omnivoice.utils.common import fix_random_seed BIN = "../build/omnivoice-tts" MODEL_LM_T = "../models/omnivoice-base-{q}.gguf" MODEL_CDC_T = "../models/omnivoice-tokenizer-{q}.gguf" CKPT = "../checkpoints/OmniVoice" DUMP_CPP = "cpp" DUMP_PT = "python" def ensure_dir(path): os.makedirs(path, exist_ok=True) def save_dump(path, data): """Write a tensor in the C++ debug.h format : [ndim:i32] [shape:i32 x ndim] [data:f32 x numel] """ if isinstance(data, torch.Tensor): data = data.detach().to(torch.float32).cpu().numpy() data = np.ascontiguousarray(data.astype(np.float32)) shape = data.shape with open(path, "wb") as f: f.write(struct.pack("i", len(shape))) for s in shape: f.write(struct.pack("i", s)) f.write(data.tobytes()) def load_dump(path): """Inverse of save_dump : returns (data:f32 numpy, shape:tuple).""" raw = np.fromfile(path, dtype=np.uint8) ndim = int(np.frombuffer(raw[0:4], dtype=np.int32)[0]) shape = tuple(int(x) for x in np.frombuffer(raw[4:4 + 4 * ndim], dtype=np.int32)) body = np.frombuffer(raw[4 + 4 * ndim:], dtype=np.float32) return body.reshape(shape), shape def cos(a, b): a = a.astype(np.float64).ravel() b = b.astype(np.float64).ravel() n = min(len(a), len(b)) a, b = a[:n], b[:n] d = float(np.linalg.norm(a) * np.linalg.norm(b)) return float(np.dot(a, b) / d) if d > 1e-10 else 0.0 def stft_cos(a, b, win=2048, hop=512): # STFT magnitude cosine. Drops phase, so a constant time shift between # the two waveforms does not collapse the score. The plain cos() on # raw samples falls to ~0 the moment chunks land a few samples apart. a = a.astype(np.float64).ravel() b = b.astype(np.float64).ravel() n = min(len(a), len(b)) a, b = a[:n], b[:n] window = np.hanning(win) frames = (n - win) // hop + 1 if frames <= 0: return 0.0 sa = np.zeros((frames, win // 2 + 1)) sb = np.zeros((frames, win // 2 + 1)) for i in range(frames): s = i * hop sa[i] = np.abs(np.fft.rfft(a[s:s + win] * window)) sb[i] = np.abs(np.fft.rfft(b[s:s + win] * window)) return cos(sa.ravel(), sb.ravel()) def install_hooks(model, dump_dir): # First call to _prepare_embed_inputs at step 0 returns the input embedding # right before layer 0 of the LLM, mirroring the C++ inputs_embeds dump. # Also captures the raw input_ids row k=0 for cond and uncond, so any # token-level divergence localizes upstream of the embed lookup. seen_embed = {"done": False} orig_prepare = model._prepare_embed_inputs def hooked_prepare(input_ids, audio_mask): out = orig_prepare(input_ids, audio_mask) if not seen_embed["done"] and out.dim() == 3 and out.shape[0] >= 2: cond = out[0].detach().to(torch.float32).cpu().numpy() uncond = out[1].detach().to(torch.float32).cpu().numpy() save_dump(os.path.join(dump_dir, "lm-hidden-step0-cond-embed.bin"), cond) save_dump(os.path.join(dump_dir, "lm-hidden-step0-uncond-embed.bin"), uncond) # Style and text tokens duplicate across all K codebooks so k=0 # carries the full sequence for diagnostic. Cast to f32 keeps the # debug.h binary format identical on both sides. cond_ids = input_ids[0, 0, :].detach().to(torch.float32).cpu().numpy() uncond_ids = input_ids[1, 0, :].detach().to(torch.float32).cpu().numpy() save_dump(os.path.join(dump_dir, "prompt-cond-ids.bin"), cond_ids) save_dump(os.path.join(dump_dir, "prompt-uncond-ids.bin"), uncond_ids) seen_embed["done"] = True return out model._prepare_embed_inputs = hooked_prepare # Bisection : dump cond and uncond hidden states after a few layers so a # mismatch can be localized within the 28 layer Qwen3 stack. bisect_layers = [0, 6, 13, 20] seen_layers = {idx: False for idx in bisect_layers} def make_layer_hook(layer_idx): def hook(module, inputs, output): if seen_layers[layer_idx]: return h = output[0] if isinstance(output, tuple) else output if h.dim() == 3 and h.shape[0] >= 2: cond = h[0].detach().to(torch.float32).cpu().numpy() uncond = h[1].detach().to(torch.float32).cpu().numpy() save_dump(os.path.join(dump_dir, f"lm-hidden-step0-cond-l{layer_idx}.bin"), cond) save_dump(os.path.join(dump_dir, f"lm-hidden-step0-uncond-l{layer_idx}.bin"), uncond) seen_layers[layer_idx] = True return hook for layer_idx in bisect_layers: model.llm.layers[layer_idx].register_forward_hook(make_layer_hook(layer_idx)) # First call to audio_heads corresponds to step 0 of the MaskGIT loop. # The input to audio_heads is the final hidden state, shape [B, S, D], # mirroring what the C++ side reads back via dump_hidden_dir before the # lm_head matmul. We dump cond (b=0) and uncond (b=1) separately. seen_hidden = {"done": False} def pre_audio_heads(module, inputs): if not seen_hidden["done"]: h = inputs[0] if h.dim() == 3 and h.shape[0] >= 2: cond = h[0].detach().to(torch.float32).cpu().numpy() uncond = h[1].detach().to(torch.float32).cpu().numpy() save_dump(os.path.join(dump_dir, "lm-hidden-step0-cond.bin"), cond) save_dump(os.path.join(dump_dir, "lm-hidden-step0-uncond.bin"), uncond) seen_hidden["done"] = True model.audio_heads.register_forward_pre_hook(pre_audio_heads) # First call to _predict_tokens_with_scoring corresponds to step 0 of the # MaskGIT loop. Capture cond and uncond logits in [K, T, V] layout (squeeze # the batch axis to match the C++ dump shape). Replicate the predict math # locally so log_probs / pred_tokens / scores can all be dumped from a # single source of truth on the Python side. seen = {"step0": False, "mg_tokens": False, "audio": False} orig_pred = model._predict_tokens_with_scoring def hooked_pred(c_logits, u_logits, gen_config): pred_tokens, scores = orig_pred(c_logits, u_logits, gen_config) if not seen["step0"]: if gen_config.guidance_scale != 0: c_lp = torch.nn.functional.log_softmax(c_logits, dim=-1) u_lp = torch.nn.functional.log_softmax(u_logits, dim=-1) log_probs = torch.log_softmax( c_lp + gen_config.guidance_scale * (c_lp - u_lp), dim=-1) else: log_probs = torch.nn.functional.log_softmax(c_logits, dim=-1) log_probs = log_probs.clone() log_probs[..., model.config.audio_mask_id] = float("-inf") c = c_logits.detach().to(torch.float32).cpu().numpy() u = u_logits.detach().to(torch.float32).cpu().numpy() if c.ndim == 4: c = c[0] if u.ndim == 4: u = u[0] save_dump(os.path.join(dump_dir, "lm-logits-step0-cond.bin"), c) save_dump(os.path.join(dump_dir, "lm-logits-step0-uncond.bin"), u) lp_arr = log_probs.detach().to(torch.float32).cpu().numpy() if lp_arr.ndim == 4: lp_arr = lp_arr[0] save_dump(os.path.join(dump_dir, "mg-log-probs-step0.bin"), lp_arr) pt_arr = pred_tokens.detach().to(torch.float32).cpu().numpy() sc_arr = scores.detach().to(torch.float32).cpu().numpy() if pt_arr.ndim == 3: pt_arr = pt_arr[0] if sc_arr.ndim == 3: sc_arr = sc_arr[0] save_dump(os.path.join(dump_dir, "mg-pred-tokens-step0.bin"), pt_arr) save_dump(os.path.join(dump_dir, "mg-scores-step0.bin"), sc_arr) seen["step0"] = True return pred_tokens, scores model._predict_tokens_with_scoring = hooked_pred orig_generate = model._generate_iterative def hooked_generate(task, gen_config): out = orig_generate(task, gen_config) # out is a list of (K, T_i) long tensors, one per batch item. # In chunked mode this hook fires once per chunk; only the first # call mirrors the chunk 0 dump on the C++ side. if not seen["mg_tokens"]: save_dump(os.path.join(dump_dir, "mg-tokens.bin"), out[0]) seen["mg_tokens"] = True return out model._generate_iterative = hooked_generate orig_decode = model.audio_tokenizer.decode def hooked_decode(*args, **kwargs): out = orig_decode(*args, **kwargs) # The audio tokenizer returns either a tensor or a wrapper holding # audio_values shape [B, C, N]. Unwrap and dump the first item mono. # Same first-chunk guard as the mg-tokens hook above. if seen["audio"]: return out wav = getattr(out, "audio_values", out) if isinstance(wav, torch.Tensor): arr = wav.detach().to(torch.float32).cpu().numpy() else: arr = np.asarray(wav, dtype=np.float32) if arr.ndim == 3: arr = arr[0, 0] elif arr.ndim == 2: arr = arr[0] save_dump(os.path.join(dump_dir, "output-audio.bin"), arr) seen["audio"] = True return out model.audio_tokenizer.decode = hooked_decode def main(): ap = argparse.ArgumentParser() ap.add_argument("--prompt", default="../examples/prompt.txt") ap.add_argument("--seed", type=int, default=42) ap.add_argument("--instruct", default="male, young adult, moderate pitch") ap.add_argument("--lang", default="English") ap.add_argument("--duration", type=float, default=None) ap.add_argument("--quant", default="F32", help="quantization suffix for GGUF (default: F32, e.g. BF16, Q8_0, Q4_K_M)") ap.add_argument("--out-cpp", default="cpp/tts-cpp.wav") ap.add_argument("--out-pt", default="python/tts-python.wav") args = ap.parse_args() model_lm = MODEL_LM_T.format(q=args.quant) model_cdc = MODEL_CDC_T.format(q=args.quant) for p in (model_lm, model_cdc): if not os.path.isfile(p): print(f"[Error] GGUF not found: {p}") sys.exit(1) print(f"[Quant] {args.quant} -> {model_lm} + {model_cdc}") ensure_dir(DUMP_CPP) ensure_dir(DUMP_PT) os.makedirs(os.path.dirname(args.out_cpp) or ".", exist_ok=True) with open(args.prompt, "r", encoding="utf-8") as f: text = f.read().strip() print(f"[Input] Prompt: {len(text)} chars: {text[:60]}{'...' if len(text) > 60 else ''}") print(f"[Input] Instruct: {args.instruct}") print(f"[Input] Language: {args.lang}") print(f"[Input] Seed: {args.seed}") # Python reference path : F32, voice design male, no pre or post process. fix_random_seed(args.seed) device = "cuda" if torch.cuda.is_available() else "cpu" model = OmniVoice.from_pretrained( CKPT, torch_dtype=torch.float32, attn_implementation="eager", ).to(device).eval() install_hooks(model, DUMP_PT) gen_kwargs = dict( text=text, language=args.lang, instruct=args.instruct, duration=args.duration, ) audios = model.generate(**gen_kwargs) audio_pt = np.asarray(audios[0], dtype=np.float32) sf.write(args.out_pt, audio_pt, 24000, subtype="FLOAT") print(f"[Python] Audio: {audio_pt.shape[0]} samples {audio_pt.shape[0] / 24000:.2f}s -> {args.out_pt}") # Free the GPU before launching the C++ binary so it has room to load # the F32 GGUFs without fighting for VRAM. del model torch.cuda.empty_cache() # C++ path : same text, same instruct, same seed, F32 GGUF weights, # dumps under cpp/. cmd = [ BIN, "--model", model_lm, "--codec", model_cdc, "--seed", str(args.seed), "--instruct", args.instruct, "--lang", args.lang, "--format", "wav32", "--dump", DUMP_CPP, "--no-fa", "-o", args.out_cpp, ] if args.duration: cmd += ["--duration", str(args.duration)] print(f"[GGML] Cmd: {' '.join(cmd)}") r = subprocess.run(cmd, input=text, text=True) if r.returncode != 0: sys.exit(r.returncode) audio_cpp, sr = sf.read(args.out_cpp) if audio_cpp.ndim > 1: audio_cpp = audio_cpp[:, 0] audio_cpp = audio_cpp.astype(np.float32) print(f"[GGML] Audio: {audio_cpp.shape[0]} samples {sr} Hz {audio_cpp.shape[0] / sr:.2f}s -> {args.out_cpp}") # Cossim in pipeline order: prompt-ids -> embed -> l0 -> l6 -> l13 -> l20 # -> final -> logits -> tokens -> audio. Cond and uncond on the same line # so a drift localizes immediately to the originating stage. def pair(name): a, _ = load_dump(os.path.join(DUMP_CPP, name)) b, _ = load_dump(os.path.join(DUMP_PT, name)) return a, b def ids_exact(a, b): n = min(a.size, b.size) ai = a.astype(np.int64).ravel()[:n] bi = b.astype(np.int64).ravel()[:n] diffs = np.where(ai != bi)[0] return 100.0 * float(np.mean(ai == bi)), diffs, ai, bi ca, cb = pair("prompt-cond-ids.bin") ua, ub = pair("prompt-uncond-ids.bin") cm, cd, cai, cbi = ids_exact(ca, cb) um, ud, uai, ubi = ids_exact(ua, ub) print(f"[Cossim] PromptIDs cond exact: {cm:.2f}% uncond exact: {um:.2f}%") for s in cd[:20]: print(f"[Cossim] PromptIDs cond diff at s={s}: ggml={cai[s]} python={cbi[s]}") for s in ud[:20]: print(f"[Cossim] PromptIDs uncond diff at s={s}: ggml={uai[s]} python={ubi[s]}") stages = [ ("Embed", "lm-hidden-step0-{}-embed.bin"), ("L0", "lm-hidden-step0-{}-l0.bin"), ("L6", "lm-hidden-step0-{}-l6.bin"), ("L13", "lm-hidden-step0-{}-l13.bin"), ("L20", "lm-hidden-step0-{}-l20.bin"), ("Final", "lm-hidden-step0-{}.bin"), ("Logits", "lm-logits-step0-{}.bin"), ] def metric(a, b): n = min(a.size, b.size) af = a.astype(np.float64).ravel()[:n] bf = b.astype(np.float64).ravel()[:n] d = np.abs(af - bf) nrm_a = float(np.linalg.norm(af)) nrm_b = float(np.linalg.norm(bf)) c = float(np.dot(af, bf) / (nrm_a * nrm_b)) if nrm_a > 1e-10 and nrm_b > 1e-10 else 0.0 return c, float(d.max()), float(d.mean()) for label, fmt in stages: ca, cb = pair(fmt.format("cond")) ua, ub = pair(fmt.format("uncond")) cc, cmax, cmean = metric(ca, cb) uc, umax, umean = metric(ua, ub) print(f"[Cossim] {label} cond cos: {cc:.6f} max: {cmax:.4e} mean: {cmean:.4e} uncond cos: {uc:.6f} max: {umax:.4e} mean: {umean:.4e}") pa, pb = pair("mg-pred-tokens-step0.bin") n = min(pa.size, pb.size) ai = pa.astype(np.int64).ravel()[:n] bi = pb.astype(np.int64).ravel()[:n] diffs = np.where(ai != bi)[0] print(f"[Cossim] Step0Tokens exact: {100.0 * float((ai == bi).mean()):.2f}% diffs: {diffs.size}") sa, sb = pair("mg-scores-step0.bin") sd = np.abs(sa - sb) print(f"[Cossim] Step0Scores cos: {cos(sa, sb):.6f} max_abs_diff: {sd.max():.6f} mean_abs_diff: {sd.mean():.6f}") la, lb = pair("mg-log-probs-step0.bin") finite = np.isfinite(la) & np.isfinite(lb) laf = la[finite]; lbf = lb[finite] ld = np.abs(laf - lbf) print(f"[Cossim] Step0LogProbs cos: {cos(laf, lbf):.6f} max_abs_diff: {ld.max():.6f} mean_abs_diff: {ld.mean():.6f}") ta, tb = pair("mg-tokens.bin") n = min(ta.size, tb.size) ai = ta.astype(np.int64).ravel()[:n] bi = tb.astype(np.int64).ravel()[:n] print(f"[Cossim] Tokens: {cos(ta, tb):.6f} exact: {100.0 * float(np.mean(ai == bi)):.2f}%") aa, ab = pair("output-audio.bin") print(f"[Cossim] Audio: {cos(aa, ab):.6f}") n = min(audio_cpp.size, audio_pt.size) print(f"[Cossim] WAV stft_cos: {stft_cos(audio_cpp[:n], audio_pt[:n]):.6f} samples: {n}") if __name__ == "__main__": main()