Files
qwentts.cpp/tests/debug-clone-cossim.py
Pascal 7b6ed4f6db codec: drop the fused streaming tail
The fused mode appended the codec stream tail to the predictor frame
graph, so one compute produced both a frame's codes and its 80 ms of
audio with no host round trip. The experiment applied to max_batch 1
with a streaming synthesis only, it cost throughput against the
buffered flush that stays the default, and it kept a second frame
graph, its ring inputs and an init flag alive for that single case. It
is not worth keeping.

Remove the tail helpers, the fused graph of CodePredGraphSet, the
codec_fused field of qt_init_params, the --codec-fused flag of both
tools and the harness switch that exercised it. The predictor frame
unroll and the in graph sampler are untouched.
2026-08-05 18:20:08 +02:00

491 lines
22 KiB
Python
Executable File

#!/usr/bin/env python3
"""Cossim debug : C++ qwen-tts vs Python Qwen3-TTS on the Voice Clone Mode B
(ICL) path.
Inputs (relative to CWD = tests/) :
../examples/prompt.txt target text fed to both pipelines
../examples/freeman.wav reference audio for cloning
../examples/freeman.txt transcript of the reference audio
Default mode is greedy on both sides, non_streaming_mode=False which is
the ICL branch upstream : text + codec streams are aligned to the codec
length, the shorter one padded with tts_pad / truncated as needed.
Cote Python the speaker embedding is captured directly via
model.extract_speaker_embedding, and the reference codec frames via
model.speech_tokenizer.encode. Both intermediates land as spk-emb.bin
and ref-codes.bin and are compared against the C++ side dumps emitted
by pipeline-tts.cpp when --ref-wav and --ref-text are set.
Dumps land in cpp/clone/ (C++) and python/clone/ (Python).
"""
import argparse
import os
import subprocess
import sys
import librosa
import numpy as np
import soundfile as sf
import torch
import cossim_common as cc
MODEL_T = "../models/qwen-talker-1.7b-base-{q}.gguf"
MODEL_CDC_T = "../models/qwen-tokenizer-12hz-{q}.gguf"
CKPT = "../checkpoints/Qwen3-TTS-12Hz-1.7B-Base"
DUMP_CPP = "cpp/clone"
DUMP_PT = "python/clone"
# Mode B adds two pre-talker stages to the standard list : the speaker
# embedding extracted from the reference audio (ECAPA forward, projected to
# talker hidden), and the reference codec frames at 12.5 Hz. Plus three
# bisection stages for the 12Hz codec encoder (SEANet output, encoder
# transformer output, post-downsample = pre-FSQ latents), the mel front end
# (mel-mag and mel-spk), and four ECAPA forward bisection stages (frontend
# conv0 output, third SE-Res2Net block output, MFA output, ASP output).
STAGES_CLONE = cc.STAGES_STANDARD + [
("MelHann", "mel-hann.bin"),
("MelBasis", "mel-basis.bin"),
("MelMag", "mel-mag.bin"),
("MelSpk", "mel-spk.bin"),
("SeanetInit", "seanet-init.bin"),
("SeanetResnet0", "seanet-resnet0.bin"),
("SeanetStage0", "seanet-stage0.bin"),
("SeanetStage1", "seanet-stage1.bin"),
("SeanetStage3", "seanet-stage3.bin"),
("SeanetOut", "seanet-out.bin"),
("EncTransformer", "enc-transformer-out.bin"),
("CodecPreFSQ", "codec-pre-fsq.bin"),
("SpkFrontend", "spk-frontend.bin"),
("SpkBlock3", "spk-block3.bin"),
("SpkMFA", "spk-mfa.bin"),
("SpkASP", "spk-asp.bin"),
("SpeakerEmb", "spk-emb.bin"),
]
def install_clone_hooks(model, dump_dir):
"""Capture the codec encoder bisection points (SEANet, encoder_transformer,
downsample = pre-FSQ latents), the ECAPA mel front end input, and four
ECAPA forward bisection points (frontend conv0 output, third SE-Res2Net
block output, MFA output, ASP output). Mirrors exactly what
pipeline-codec.cpp and speaker-encoder-extract.h dump on the C++ side,
with matching shapes : [T, 512] for the codec stages, [T_frames, 128]
for the speaker mel, [T_frames, 512] for spk-frontend / spk-block3,
[T_frames, 1536] for spk-mfa, and [1, 3072] for spk-asp."""
enc = model.speech_tokenizer.model.encoder
seen_seanet = {"done": False}
def hook_seanet(module, args, output):
if seen_seanet["done"]:
return
out = output[0] if isinstance(output, tuple) else output
# output shape : [B=1, C=512, T_emb] channel-first from MimiEncoder.
cc.save_dump(os.path.join(dump_dir, "seanet-out.bin"), out[0].transpose(0, 1).contiguous())
seen_seanet["done"] = True
enc.encoder.register_forward_hook(hook_seanet)
seen_enct = {"done": False}
def hook_enct(module, args, output):
if seen_enct["done"]:
return
out = output[0] if isinstance(output, tuple) else output
# encoder_transformer is fed [B, T, 512] T-first and returns the
# same shape, so no transpose needed before the [0] slice.
cc.save_dump(os.path.join(dump_dir, "enc-transformer-out.bin"), out[0])
seen_enct["done"] = True
enc.encoder_transformer.register_forward_hook(hook_enct)
seen_down = {"done": False}
def hook_down(module, args, output):
if seen_down["done"]:
return
out = output[0] if isinstance(output, tuple) else output
# downsample output : [B=1, C=512, T] channel-first, transpose to
# [T, 512] to match the C++ post-downsample dump.
cc.save_dump(os.path.join(dump_dir, "codec-pre-fsq.bin"), out[0].transpose(0, 1).contiguous())
seen_down["done"] = True
enc.downsample.register_forward_hook(hook_down)
# SEANet bisection. enc.encoder is a MimiEncoder whose .layers ModuleList
# holds, in order : [0] init MimiConv1d, [1] resnet, [2] ELU, [3] down 4x,
# [4] resnet, [5] ELU, [6] down 5x, [7] resnet, [8] ELU, [9] down 6x,
# [10] resnet, [11] ELU, [12] down 8x, [13] ELU, [14] last MimiConv1d.
# We hook the init conv and the three downsample convs the C++ side
# exposes as out-params in seanet_encoder_forward.
sn_layers = enc.encoder.layers
seen_sn_init = {"done": False}
def hook_sn_init(module, args, output):
if seen_sn_init["done"]:
return
out = output[0] if isinstance(output, tuple) else output
# MimiConv1d output : [B=1, OC, T] channel-first -> [T, OC] T-first.
cc.save_dump(os.path.join(dump_dir, "seanet-init.bin"), out[0].transpose(0, 1).contiguous())
seen_sn_init["done"] = True
sn_layers[0].register_forward_hook(hook_sn_init)
seen_sn_r0 = {"done": False}
def hook_sn_resnet0(module, args, output):
if seen_sn_r0["done"]:
return
out = output[0] if isinstance(output, tuple) else output
# MimiResnetBlock output : [B=1, OC, T] channel-first -> [T, OC] T-first.
cc.save_dump(os.path.join(dump_dir, "seanet-resnet0.bin"), out[0].transpose(0, 1).contiguous())
seen_sn_r0["done"] = True
sn_layers[1].register_forward_hook(hook_sn_resnet0)
seen_sn_s0 = {"done": False}
def hook_sn_stage0(module, args, output):
if seen_sn_s0["done"]:
return
out = output[0] if isinstance(output, tuple) else output
cc.save_dump(os.path.join(dump_dir, "seanet-stage0.bin"), out[0].transpose(0, 1).contiguous())
seen_sn_s0["done"] = True
sn_layers[3].register_forward_hook(hook_sn_stage0)
seen_sn_s1 = {"done": False}
def hook_sn_stage1(module, args, output):
if seen_sn_s1["done"]:
return
out = output[0] if isinstance(output, tuple) else output
cc.save_dump(os.path.join(dump_dir, "seanet-stage1.bin"), out[0].transpose(0, 1).contiguous())
seen_sn_s1["done"] = True
sn_layers[6].register_forward_hook(hook_sn_stage1)
seen_sn_s3 = {"done": False}
def hook_sn_stage3(module, args, output):
if seen_sn_s3["done"]:
return
out = output[0] if isinstance(output, tuple) else output
cc.save_dump(os.path.join(dump_dir, "seanet-stage3.bin"), out[0].transpose(0, 1).contiguous())
seen_sn_s3["done"] = True
sn_layers[12].register_forward_hook(hook_sn_stage3)
seen_mel = {"done": False}
def hook_spk_pre(module, args, kwargs):
if seen_mel["done"]:
return
# mels arrives as args[0] with shape [B=1, T_frames, n_mels=128]
# post the .transpose(1, 2) inside extract_speaker_embedding. The
# C++ side now dumps the same T-first layout, so we keep mels[0]
# as is to preserve [T_frames, n_mels].
mels = args[0] if args else kwargs.get("mels", None)
if mels is None or mels.dim() != 3:
return
cc.save_dump(os.path.join(dump_dir, "mel-spk.bin"), mels[0])
seen_mel["done"] = True
model.speaker_encoder.register_forward_pre_hook(hook_spk_pre, with_kwargs=True)
# ECAPA forward bisection. blocks[0] is the frontend TimeDelayNetBlock
# mapped to spk_tdnn(conv0) on the C++ side. blocks[3] is the third
# SE-Res2Net block, mapped to the C++ blocks[2] output. mfa and asp
# speak for themselves. All these modules ingest channel-first
# [B, C, T] tensors so we transpose to [T, C] before save_dump for a
# direct compare against the C++ ne=(C, T) raw memory dumps.
spk = model.speaker_encoder
seen_front = {"done": False}
def hook_frontend(module, args, output):
if seen_front["done"]:
return
out = output[0] if isinstance(output, tuple) else output
# output shape : [B=1, 512, T_frames] channel-first.
cc.save_dump(os.path.join(dump_dir, "spk-frontend.bin"), out[0].transpose(0, 1).contiguous())
seen_front["done"] = True
spk.blocks[0].register_forward_hook(hook_frontend)
seen_blk3 = {"done": False}
def hook_block3(module, args, output):
if seen_blk3["done"]:
return
out = output[0] if isinstance(output, tuple) else output
# output shape : [B=1, 512, T_frames] channel-first.
cc.save_dump(os.path.join(dump_dir, "spk-block3.bin"), out[0].transpose(0, 1).contiguous())
seen_blk3["done"] = True
spk.blocks[3].register_forward_hook(hook_block3)
seen_mfa = {"done": False}
def hook_mfa(module, args, output):
if seen_mfa["done"]:
return
out = output[0] if isinstance(output, tuple) else output
# output shape : [B=1, 1536, T_frames] channel-first.
cc.save_dump(os.path.join(dump_dir, "spk-mfa.bin"), out[0].transpose(0, 1).contiguous())
seen_mfa["done"] = True
spk.mfa.register_forward_hook(hook_mfa)
seen_asp = {"done": False}
def hook_asp(module, args, output):
if seen_asp["done"]:
return
out = output[0] if isinstance(output, tuple) else output
# output shape : [B=1, 3072, 1] from AttentiveStatisticsPooling.
# Transpose to [1, 3072] to match the C++ ne=(3072, 1) raw layout.
cc.save_dump(os.path.join(dump_dir, "spk-asp.bin"), out[0].transpose(0, 1).contiguous())
seen_asp["done"] = True
spk.asp.register_forward_hook(hook_asp)
def dump_mel_constants(dump_dir):
"""Reproduce the speaker encoder mel front end CPU constants the same
way the upstream mel_spectrogram() builds them (torch.hann_window for
the window and librosa.filters.mel for the Slaney filterbank), and
save them under dump_dir/mel-hann.bin and dump_dir/mel-basis.bin so
they can be paired with the C++ side dumps."""
import librosa
n_fft = 1024
n_mels = 128
sr = 24000
fmin = 0.0
fmax = 12000.0
hann = torch.hann_window(n_fft, periodic=True).numpy().astype(np.float32)
cc.save_dump(os.path.join(dump_dir, "mel-hann.bin"), hann)
mel_basis = librosa.filters.mel(sr=sr, n_fft=n_fft, n_mels=n_mels, fmin=fmin, fmax=fmax)
cc.save_dump(os.path.join(dump_dir, "mel-basis.bin"), mel_basis.astype(np.float32))
def dump_mel_mag_python(ref_wav, dump_dir):
"""Reproduce the upstream mel_spectrogram STFT path (same n_fft / hop /
window / pad as modeling_qwen3_tts.mel_spectrogram) and dump the post
magnitude tensor [T_frames, n_freq] for direct pairing with the C++
spk.mag_dump output. This isolates the STFT step from the mel filter."""
n_fft = 1024
hop = 256
win = 1024
padding = (n_fft - hop) // 2
y = torch.from_numpy(ref_wav).unsqueeze(0)
y = torch.nn.functional.pad(y.unsqueeze(1), (padding, padding), mode="reflect").squeeze(1)
spec = torch.stft(
y, n_fft, hop_length=hop, win_length=win,
window=torch.hann_window(win, periodic=True),
center=False, pad_mode="reflect", normalized=False,
onesided=True, return_complex=True,
)
mag = torch.sqrt(torch.view_as_real(spec).pow(2).sum(-1) + 1e-9)
# mag shape : [B=1, n_freq=513, T_frames]. Transpose to [T_frames, n_freq].
cc.save_dump(os.path.join(dump_dir, "mel-mag.bin"), mag[0].transpose(0, 1).contiguous())
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--prompt", default="../examples/prompt.txt")
ap.add_argument("--ref-wav", default="../examples/freeman.wav",
help="reference WAV path for voice cloning")
ap.add_argument("--ref-text", default="../examples/freeman.txt",
help="path to a UTF-8 file with the transcript of ref-wav")
ap.add_argument("--seed", type=int, default=42)
ap.add_argument("--lang", default="english")
ap.add_argument("--quant", default="F32",
help="GGUF quantization suffix (F32, BF16, Q8_0, Q4_K_M)")
ap.add_argument("--out-pt", default=os.path.join(DUMP_PT, "clone-python.wav"))
ap.add_argument("--out-cpp", default=os.path.join(DUMP_CPP, "clone-cpp.wav"))
ap.add_argument("--max-new-tokens", type=int, default=64)
ap.add_argument("--trace", action="store_true",
help="print per sample u and idx for the first 32 samples")
args = ap.parse_args()
cc.ensure_dir(DUMP_PT)
cc.ensure_dir(DUMP_CPP)
os.makedirs(os.path.dirname(args.out_pt) or ".", exist_ok=True)
# Reproduce the upstream mel front end CPU constants (torch.hann_window
# + librosa.filters.mel) and dump them so they pair with the C++ side
# dumps emitted by speaker-encoder-extract.h.
dump_mel_constants(DUMP_PT)
with open(args.prompt, "r", encoding="utf-8") as f:
text = f.read().strip()
with open(args.ref_text, "r", encoding="utf-8") as f:
ref_text = f.read().strip()
print(f"[Input] Prompt: {len(text)} chars: {text[:60]}{'...' if len(text) > 60 else ''}")
print(f"[Input] RefAudio: {args.ref_wav}")
print(f"[Input] RefText: {len(ref_text)} chars: {ref_text[:60]}{'...' if len(ref_text) > 60 else ''}")
print(f"[Input] Lang: {args.lang} Seed: {args.seed} MaxNewTokens: {args.max_new_tokens}")
print(f"[Input] Mode: greedy ICL")
torch.manual_seed(args.seed)
np.random.seed(args.seed)
cc.set_trace(args.trace)
cc.register_qwen3_tts()
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"[Python] Device: {device}")
model = cc.AutoModel.from_pretrained(
CKPT,
device_map=device,
dtype=torch.float32,
attn_implementation="eager",
).eval()
processor = cc.AutoProcessor.from_pretrained(CKPT, fix_mistral_regex=True)
# Install codec encoder + ECAPA front end hooks before any encode call,
# so the freshly captured intermediates land in DUMP_PT/*.bin alongside
# the talker stages installed further down by cc.install_hooks.
install_clone_hooks(model, DUMP_PT)
# Load reference WAV. Resample to 24 kHz if needed since both the speaker
# encoder and the codec tokenizer expect 24 kHz mono input.
ref_wav, ref_sr = sf.read(args.ref_wav, always_2d=False)
if ref_wav.ndim > 1:
ref_wav = ref_wav[:, 0]
ref_wav = ref_wav.astype(np.float32)
target_sr = model.speaker_encoder_sample_rate
if ref_sr != target_sr:
# Match C++ side audio_resample.h which is a torchaudio.functional.resample
# reimplementation. Using librosa.resample here would introduce a phase
# drift between the two waveforms that propagates through the SEANet
# stack and shows up as a measurable cossim drop on the codec encoder
# bisection stages.
import torchaudio
ref_wav = torchaudio.functional.resample(
torch.from_numpy(ref_wav.astype(np.float32)),
int(ref_sr), int(target_sr),
).numpy()
ref_sr = target_sr
print(f"[Python] RefWav: {ref_wav.shape[0]} samples {ref_sr} Hz {ref_wav.shape[0]/ref_sr:.2f}s")
# Reproduce the upstream STFT magnitude on the same ref_wav so the
# mel-mag.bin pair scopes whether the divergence sits in the STFT or
# in the mel filter. This runs before the model speaker_encoder hook
# fires so both intermediates land in DUMP_PT before the test compare.
dump_mel_mag_python(ref_wav, DUMP_PT)
# Extract speaker embedding via ECAPA forward, projected to talker hidden.
spk_emb = model.extract_speaker_embedding(audio=ref_wav, sr=ref_sr)
print(f"[Python] SpeakerEmb shape: {tuple(spk_emb.shape)} dtype: {spk_emb.dtype}")
cc.save_dump(os.path.join(DUMP_PT, "spk-emb.bin"), spk_emb)
# Encode the reference audio to 16 codebook codes at 12.5 Hz. The encode
# call returns shape [T_codec, K=16] after the internal transpose, while
# the C++ side dumps [K=16, T_codec] row major. We transpose here for a
# straight exact match comparison. The C++ side aligns the number of
# samples to a multiple of the codec hop length (1920) before feeding
# the tokenizer, so we apply the same truncation upstream to keep T_codec
# comparable across the codec encoder bisection stages.
HOP = 1920
aligned_T = (ref_wav.shape[0] // HOP) * HOP
ref_wav_aln = ref_wav[:aligned_T]
cc.save_dump(os.path.join(DUMP_PT, "audio-input.bin"), torch.from_numpy(ref_wav_aln.astype(np.float32)))
enc = model.speech_tokenizer.encode([ref_wav_aln], sr=int(ref_sr))
ref_code_pt = enc.audio_codes[0]
ref_code_kt = ref_code_pt.transpose(0, 1).contiguous()
print(f"[Python] RefCodes shape: {tuple(ref_code_kt.shape)} (K, T_codec)")
cc.save_dump_i32(os.path.join(DUMP_PT, "ref-codes.bin"), ref_code_kt)
# Tokenize the utterance and the reference text.
assistant_text = f"<|im_start|>assistant\n{text}<|im_end|>\n<|im_start|>assistant\n"
inp_utt = processor(text=assistant_text, return_tensors="pt", padding=True)
input_ids = inp_utt["input_ids"].to(device)
if input_ids.dim() == 1:
input_ids = input_ids.unsqueeze(0)
print(f"[Python] InputIds shape: {tuple(input_ids.shape)}")
cc.save_dump_i32(os.path.join(DUMP_PT, "prompt-ids.bin"), input_ids[0])
ref_text_wrap = f"<|im_start|>assistant\n{ref_text}<|im_end|>\n"
inp_ref = processor(text=ref_text_wrap, return_tensors="pt", padding=True)
ref_ids = inp_ref["input_ids"].to(device)
if ref_ids.dim() == 1:
ref_ids = ref_ids.unsqueeze(0)
print(f"[Python] RefIds shape: {tuple(ref_ids.shape)}")
cc.save_dump_i32(os.path.join(DUMP_PT, "ref-ids.bin"), ref_ids[0])
cc.install_hooks(model, DUMP_PT)
# Custom subtalker_* kwargs are forwarded to talker.forward but not
# declared on GenerationMixin, so transformers 4.57 rejects them under
# the strict validator. Disable it on the talker only.
model.talker._validate_model_kwargs = lambda *a, **k: None
# voice_clone_prompt dict mirrors what _prompt_items_to_voice_clone_prompt
# builds for a single ICL prompt item : ref_code is the [T_codec, K]
# tensor, ref_spk_embedding is the [hidden] tensor, x_vector_only=False
# and icl_mode=True together select the mode B branch upstream.
voice_clone_prompt_dict = dict(
ref_code = [ref_code_pt],
ref_spk_embedding = [spk_emb],
x_vector_only_mode = [False],
icl_mode = [True],
)
talker_codes_list, _ = model.generate(
input_ids=[input_ids],
ref_ids=[ref_ids],
voice_clone_prompt=voice_clone_prompt_dict,
languages=[args.lang],
non_streaming_mode=False,
max_new_tokens=args.max_new_tokens,
**cc.GEN_KWARGS_GREEDY,
)
codes = talker_codes_list[0]
print(f"[Python] Codes shape: {tuple(codes.shape)} (T_frames, num_code_groups)")
cc.save_dump_i32(os.path.join(DUMP_PT, "codes-full.bin"), codes)
cc.save_dump_i32(os.path.join(DUMP_PT, "codes-step0.bin"), codes[0])
# The decode path prepends the reference codes and cuts the matching
# audio prefix afterwards, mirroring generate_voice_clone exactly so the
# produced WAV only covers the freshly generated portion.
cat_codes = torch.cat([ref_code_pt.to(codes.device), codes], dim=0)
wavs, fs = model.speech_tokenizer.decode([{"audio_codes": cat_codes}])
full_wav = np.asarray(wavs[0], dtype=np.float32)
ref_len = int(ref_code_pt.shape[0])
total_len = int(cat_codes.shape[0])
cut = int(ref_len / max(total_len, 1) * full_wav.shape[0])
audio_pt = full_wav[cut:]
sf.write(args.out_pt, audio_pt, fs, subtype="FLOAT")
cc.save_dump(os.path.join(DUMP_PT, "output-audio.bin"), audio_pt)
print(f"[Python] Audio: {audio_pt.shape[0]} samples {fs} Hz {audio_pt.shape[0]/fs:.2f}s -> {args.out_pt}")
if not os.path.isfile(cc.BIN):
print(f"[Cossim] FATAL: {cc.BIN} not found, build qwen-tts first")
sys.exit(1)
model_lm = MODEL_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"[Cossim] FATAL: GGUF not found: {p}")
sys.exit(1)
print(f"[Quant] {args.quant} -> {model_lm} + {model_cdc}")
del model
if device == "cuda":
torch.cuda.empty_cache()
cmd = [
cc.BIN,
"--model", model_lm,
"--codec", model_cdc,
"--seed", str(args.seed),
"--ref-wav", args.ref_wav,
"--ref-text", args.ref_text,
"--lang", args.lang,
"--max-new", str(args.max_new_tokens),
"--dump", DUMP_CPP,
"-o", args.out_cpp,
"--greedy",
]
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}")
cc.compare_exact_i32("prompt-ids.bin", DUMP_CPP, DUMP_PT, "PromptIDs")
cc.compare_exact_i32("ref-codes.bin", DUMP_CPP, DUMP_PT, "RefCodes")
cc.compare_stages(STAGES_CLONE, DUMP_CPP, DUMP_PT)
cc.compare_exact_i32("codes-full.bin", DUMP_CPP, DUMP_PT, "CodesFull")
aa, ab = cc.pair("output-audio.bin", DUMP_CPP, DUMP_PT)
print(f"[Cossim] Audio cos: {cc.cos(aa, ab):.6f}")
n = min(aa.size, ab.size)
print(f"[Cossim] WAV stft_cos: {cc.stft_cos(aa.ravel()[:n], ab.ravel()[:n]):.6f} samples: {n}")
if __name__ == "__main__":
main()