From c72d77e968bc20e90d1cfcdd437043016d402a57 Mon Sep 17 00:00:00 2001 From: Shaw Date: Tue, 13 Oct 2026 23:00:57 -0400 Subject: [PATCH 1/3] tools/paradee: pinned Paradee-8M v1.0 PyTorch to GGUF converter convert_paradee_pt_to_gguf.py reads pytorch/text_side.pt and pytorch/decoder.pt from sahilmahendrakar/Paradee-8M-v1.0 at the revision in pins.json, after checking size and sha256 of both checkpoints, config.json and tokenizer.json, and loads them with torch.load(weights_only=True). - weight_norm pairs are folded with torch._weight_norm, so the GGUF weights equal the reference module's effective weights. - Every state-dict key must be consumed (only the unused ALBERT pooler is skipped) and every width is checked against the hparams it writes. - The GGUF carries general.architecture=paradee, all widths, both learned styles, the vocabulary (symbols + ids), the sample rate, the phase-lock parameters, the input pins and the converter's git blob sha. - F32 by default (32 MB). The output is byte-reproducible; pins.json holds its sha256 and --check-output enforces it. pins.json also pins the paradee source commit and the training scripts the parity reference imports, the reference environment, the published samples and the test fixtures. Co-Authored-By: Claude Opus 5.5 --- tools/paradee/convert_paradee_pt_to_gguf.py | 444 ++++++++++++++++++++ tools/paradee/pins.json | 93 ++++ 2 files changed, 537 insertions(+) create mode 100644 tools/paradee/convert_paradee_pt_to_gguf.py create mode 100644 tools/paradee/pins.json diff --git a/tools/paradee/convert_paradee_pt_to_gguf.py b/tools/paradee/convert_paradee_pt_to_gguf.py new file mode 100644 index 000000000..08ed16500 --- /dev/null +++ b/tools/paradee/convert_paradee_pt_to_gguf.py @@ -0,0 +1,444 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +""" +convert_paradee_pt_to_gguf.py - convert the Paradee-8M v1.0 PyTorch weights +(sahilmahendrakar/Paradee-8M-v1.0, Apache-2.0) into the GGUF that +tools/paradee loads. + +Paradee is Kokoro-82M's own StyleTTS-2 + iSTFTNet code at smaller widths, +distilled to one voice (af_heart). It ships two checkpoints: + + pytorch/text_side.pt {'model': state_dict} TextStudent preset 's+mlp' + pytorch/decoder.pt {'model': state_dict} StudentDecoder preset 'A' + +Each checkpoint holds its own learned constant style vector (text 32-d, +decoder 16-d); there is no voice .bin. The model's published runtime graph is +ONNX; this converter reads the PyTorch weights instead so that inference runs +natively in this fork. + +Every input is checked against tools/paradee/pins.json (size + sha256) before +torch.load(weights_only=True) touches it. weight_norm pairs are folded with +torch._weight_norm, the same function PyTorch uses when it removes the +parametrisation, so the GGUF weights are bit-identical to the reference +module's effective weights. Every state-dict key must be consumed (only the +unused ALBERT pooler is skipped), and every tensor shape is checked against +the hparams written to the GGUF. + +The output is byte-reproducible: the same inputs and the same converter +produce the same file. pins.json records the expected sha256. The metadata +records the input pins and the git blob sha of this script (not the commit +sha, which would make the expected output depend on the commit that pins it). + +usage: + convert_paradee_pt_to_gguf.py --model-dir \ + --output paradee-8m-v1.0-f32.gguf [--outtype f32|f16] [--check-output] +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import sys +from pathlib import Path + +import numpy as np + +_HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(_HERE.parent.parent / "gguf-py")) + +import gguf # noqa: E402 + +PINS_PATH = _HERE / "pins.json" +ARCH = "paradee" +NS = "paradee" + +# Architecture of Paradee-8M v1.0, read from the checkpoint shapes and from +# training/scripts/{student.py, student_decoder.py} at the pinned source +# commit. The converter checks every tensor against these values. +HPARAMS = { + # CustomAlbert (transformers AlbertConfig, num_hidden_groups=1). + "text.n_layer": 6, + "text.n_head": 4, + "text.d_model": 256, + "text.d_ff": 768, + "text.embd_dim": 128, + "text.vocab_size": 178, + "text.max_position": 512, + # ProsodyPredictor / TextEncoder. + "hidden_dim": 192, + "predictor.n_layer": 3, + "predictor.max_dur": 50, + "text_encoder.kernel": 5, + # Styles (learned constants, one per half). + "style.text_dim": 32, + "style.decoder_dim": 16, + # asr_proj MLP: Linear(192, 512) -> GELU -> Linear(512, 512). + "asr.dim": 512, + # StudentDecoder preset A + Kokoro iSTFTNet generator. + "decoder.adain_dim": 256, + "decoder.initial_ch": 128, + "decoder.asr_res_dim": 64, + "decoder.upsample_rates": [10, 6], + "decoder.upsample_kernels": [20, 12], + "decoder.resblock_kernels": [3, 7, 11], + "decoder.resblock_dilations": [1, 3, 5, 1, 3, 5, 1, 3, 5], + "decoder.istft_n_fft": 20, + "decoder.istft_hop": 5, + "decoder.harmonic_num": 8, + "decoder.sine_amp": 0.1, + "decoder.voiced_threshold": 10.0, + "audio.sample_rate": 24000, + # Phase-locking filter (training/scripts/phase_lock.py, export_paradee.py Lock(L=33)). + "lock.n_fft": 1024, + "lock.hop": 256, + "lock.band_lo_hz": 2000, + "lock.band_hi_hz": 8000, + "lock.smooth_frames": 33, + "lock.voiced_f0_hz": 60.0, + "lock.f0_hop": 300, + # Input contract (paradee/tts.py): [0] + ids + [0], at most 512 ids. + "input.pad_id": 0, + "input.max_ids": 512, +} + + +def sha256_file(path: Path) -> str: + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest() + + +def git_blob_sha1(path: Path) -> str: + data = path.read_bytes() + return hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest() + + +def check_pins(model_dir: Path, pins: dict) -> None: + for rel in ("pytorch/text_side.pt", "pytorch/decoder.pt", "config.json", "tokenizer.json"): + pin = pins["files"][rel] + path = model_dir / rel + if not path.is_file(): + raise SystemExit(f"missing input {path}") + size = path.stat().st_size + if size != pin["size"]: + raise SystemExit(f"{rel}: size {size} != pinned {pin['size']}") + digest = sha256_file(path) + if digest != pin["sha256"]: + raise SystemExit(f"{rel}: sha256 {digest} != pinned {pin['sha256']}") + + +def load_vocab(model_dir: Path) -> list[tuple[str, int]]: + config = json.loads((model_dir / "config.json").read_text(encoding="utf-8")) + tok = json.loads((model_dir / "tokenizer.json").read_text(encoding="utf-8")) + vocab = config["vocab"] + if config.get("sample_rate") != HPARAMS["audio.sample_rate"]: + raise SystemExit(f"config.json sample_rate {config.get('sample_rate')} != 24000") + if config.get("n_token") != HPARAMS["text.vocab_size"]: + raise SystemExit(f"config.json n_token {config.get('n_token')} != 178") + # tokenizer.json is the same vocabulary plus the '$' pad symbol at id 0. + tok_vocab = dict(tok["model"]["vocab"]) + if tok_vocab.pop("$", None) != 0 or tok_vocab != vocab: + raise SystemExit("tokenizer.json vocabulary differs from config.json") + ids = sorted(vocab.values()) + if len(set(ids)) != len(ids) or ids[0] < 1 or ids[-1] >= HPARAMS["text.vocab_size"]: + raise SystemExit("config.json vocabulary ids are not unique in 1..177") + for sym in vocab: + if len(sym) != 1: + raise SystemExit(f"vocabulary symbol {sym!r} is not one code point") + return sorted(vocab.items(), key=lambda kv: kv[1]) + + +def fold_weight_norm(sd: dict) -> dict: + """Fold weight_g/weight_v into weight with torch._weight_norm(v, g, 0).""" + import torch + + out = {} + pairs: dict[str, dict] = {} + for k, v in sd.items(): + if k.endswith(".weight_v"): + pairs.setdefault(k[: -len(".weight_v")], {})["v"] = v + elif k.endswith(".weight_g"): + pairs.setdefault(k[: -len(".weight_g")], {})["g"] = v + else: + out[k] = v + for base, p in pairs.items(): + if set(p) != {"v", "g"}: + raise SystemExit(f"incomplete weight_norm pair for {base}") + out[base + ".weight"] = torch._weight_norm(p["v"], p["g"], 0) + return out + + +def lstm_names(src: str, dst: str) -> list[tuple[str, str]]: + out = [] + for kind in ("weight_ih", "weight_hh", "bias_ih", "bias_hh"): + out.append((f"{src}.{kind}_l0", f"{dst}.{kind}_l0")) + out.append((f"{src}.{kind}_l0_reverse", f"{dst}.{kind}_l0_r")) + return out + + +def has_weight(sd: dict, base: str) -> bool: + return f"{base}.weight" in sd or f"{base}.weight_v" in sd + + +def adain_resblk_names(src: str, dst: str, sd: dict) -> list[tuple[str, str]]: + """AdainResBlk1d (kokoro istftnet): conv1/conv2, AdaIN1d norm1/norm2 (fc + + the trained InstanceNorm affine), optional conv1x1 (bias=False) and pool.""" + out = [] + for conv in ("conv1", "conv2"): + out += [(f"{src}.{conv}.weight", f"{dst}.{conv}.weight"), (f"{src}.{conv}.bias", f"{dst}.{conv}.bias")] + for norm in ("norm1", "norm2"): + for part in ("fc.weight", "fc.bias", "norm.weight", "norm.bias"): + out.append((f"{src}.{norm}.{part}", f"{dst}.{norm}.{part}")) + if has_weight(sd, f"{src}.conv1x1"): + out.append((f"{src}.conv1x1.weight", f"{dst}.conv1x1.weight")) + if has_weight(sd, f"{src}.pool"): + out += [(f"{src}.pool.weight", f"{dst}.pool.weight"), (f"{src}.pool.bias", f"{dst}.pool.bias")] + return out + + +def gen_resblock_names(src: str, dst: str) -> list[tuple[str, str]]: + """AdaINResBlock1 (Snake): convs1/convs2 x3, adain1/adain2 x3, alpha1/alpha2 x3.""" + out = [] + for j in range(3): + for branch in ("convs1", "convs2"): + out += [(f"{src}.{branch}.{j}.weight", f"{dst}.{branch}.{j}.weight"), + (f"{src}.{branch}.{j}.bias", f"{dst}.{branch}.{j}.bias")] + for branch in ("adain1", "adain2"): + for part in ("fc.weight", "fc.bias", "norm.weight", "norm.bias"): + out.append((f"{src}.{branch}.{j}.{part}", f"{dst}.{branch}.{j}.{part}")) + out += [(f"{src}.alpha1.{j}", f"{dst}.alpha1.{j}"), (f"{src}.alpha2.{j}", f"{dst}.alpha2.{j}")] + return out + + +def text_side_map(sd: dict) -> list[tuple[str, str]]: + m = [ + ("bert.embeddings.word_embeddings.weight", f"{NS}.bert.token_embd.weight"), + ("bert.embeddings.position_embeddings.weight", f"{NS}.bert.position_embd.weight"), + ("bert.embeddings.token_type_embeddings.weight", f"{NS}.bert.tok_type_embd.weight"), + ("bert.embeddings.LayerNorm.weight", f"{NS}.bert.embd_ln.weight"), + ("bert.embeddings.LayerNorm.bias", f"{NS}.bert.embd_ln.bias"), + ("bert.encoder.embedding_hidden_mapping_in.weight", f"{NS}.bert.embd_proj.weight"), + ("bert.encoder.embedding_hidden_mapping_in.bias", f"{NS}.bert.embd_proj.bias"), + ("bert_encoder.weight", f"{NS}.bert_encoder.weight"), + ("bert_encoder.bias", f"{NS}.bert_encoder.bias"), + ] + layer = "bert.encoder.albert_layer_groups.0.albert_layers.0." + for src, dst in (("attention.query", "attn_q"), ("attention.key", "attn_k"), ("attention.value", "attn_v"), + ("attention.dense", "attn_o"), ("attention.LayerNorm", "attn_ln"), + ("full_layer_layer_norm", "full_ln"), ("ffn", "ffn"), ("ffn_output", "ffn_out")): + m += [(f"{layer}{src}.weight", f"{NS}.bert.layer.{dst}.weight"), (f"{layer}{src}.bias", f"{NS}.bert.layer.{dst}.bias")] + for i in range(HPARAMS["predictor.n_layer"]): + m += lstm_names(f"predictor.text_encoder.lstms.{2 * i}", f"{NS}.predictor.de.lstm{i}") + m += [(f"predictor.text_encoder.lstms.{2 * i + 1}.fc.weight", f"{NS}.predictor.de.adaln{i}.fc.weight"), + (f"predictor.text_encoder.lstms.{2 * i + 1}.fc.bias", f"{NS}.predictor.de.adaln{i}.fc.bias")] + m += lstm_names("predictor.lstm", f"{NS}.predictor.lstm") + m += [("predictor.duration_proj.linear_layer.weight", f"{NS}.predictor.duration_proj.weight"), + ("predictor.duration_proj.linear_layer.bias", f"{NS}.predictor.duration_proj.bias")] + m += lstm_names("predictor.shared", f"{NS}.predictor.shared") + for chain in ("F0", "N"): + for i in range(3): + m += adain_resblk_names(f"predictor.{chain}.{i}", f"{NS}.predictor.{chain}.{i}", sd) + m += [(f"predictor.{chain}_proj.weight", f"{NS}.predictor.{chain}_proj.weight"), + (f"predictor.{chain}_proj.bias", f"{NS}.predictor.{chain}_proj.bias")] + m.append(("text_encoder.embedding.weight", f"{NS}.text_encoder.embd.weight")) + for i in range(HPARAMS["predictor.n_layer"]): + m += [(f"text_encoder.cnn.{i}.0.weight", f"{NS}.text_encoder.cnn.{i}.weight"), + (f"text_encoder.cnn.{i}.0.bias", f"{NS}.text_encoder.cnn.{i}.bias"), + (f"text_encoder.cnn.{i}.1.gamma", f"{NS}.text_encoder.ln.{i}.gamma"), + (f"text_encoder.cnn.{i}.1.beta", f"{NS}.text_encoder.ln.{i}.beta")] + m += lstm_names("text_encoder.lstm", f"{NS}.text_encoder.lstm") + m += [("asr_proj.0.weight", f"{NS}.asr_proj.0.weight"), ("asr_proj.0.bias", f"{NS}.asr_proj.0.bias"), + ("asr_proj.2.weight", f"{NS}.asr_proj.1.weight"), ("asr_proj.2.bias", f"{NS}.asr_proj.1.bias")] + m.append(("style", f"{NS}.style.text")) + return m + + +TEXT_SIDE_SKIPPED = ("bert.pooler.weight", "bert.pooler.bias") # ALBERT pooler: not used by TextStudent + + +def decoder_map(sd: dict) -> list[tuple[str, str]]: + m = adain_resblk_names("encode", f"{NS}.decoder.encode", sd) + for i in range(4): + m += adain_resblk_names(f"decode.{i}", f"{NS}.decoder.decode.{i}", sd) + for conv in ("F0_conv", "N_conv"): + m += [(f"{conv}.weight", f"{NS}.decoder.{conv}.weight"), (f"{conv}.bias", f"{NS}.decoder.{conv}.bias")] + m += [("asr_res.0.weight", f"{NS}.decoder.asr_res.weight"), ("asr_res.0.bias", f"{NS}.decoder.asr_res.bias")] + g = "generator" + m += [(f"{g}.m_source.l_linear.weight", f"{NS}.gen.m_source.l_linear.weight"), + (f"{g}.m_source.l_linear.bias", f"{NS}.gen.m_source.l_linear.bias")] + for i in range(2): + for part in ("weight", "bias"): + m.append((f"{g}.noise_convs.{i}.{part}", f"{NS}.gen.noise_convs.{i}.{part}")) + m.append((f"{g}.ups.{i}.{part}", f"{NS}.gen.ups.{i}.{part}")) + m += gen_resblock_names(f"{g}.noise_res.{i}", f"{NS}.gen.noise_res.{i}") + for i in range(6): + m += gen_resblock_names(f"{g}.resblocks.{i}", f"{NS}.gen.resblocks.{i}") + m += [(f"{g}.conv_post.weight", f"{NS}.gen.conv_post.weight"), (f"{g}.conv_post.bias", f"{NS}.gen.conv_post.bias")] + m.append(("style", f"{NS}.style.decoder")) + return m + + +def expected_shapes() -> dict[str, tuple[int, ...]]: + """Shapes (PyTorch order) of the tensors whose widths the C++ graph reads + from hparams. Everything else is checked for consistency at load time.""" + h = HPARAMS + d, e, ff, hid = h["text.d_model"], h["text.embd_dim"], h["text.d_ff"], h["hidden_dim"] + st, sd_, asr = h["style.text_dim"], h["style.decoder_dim"], h["asr.dim"] + W, uic = h["decoder.adain_dim"], h["decoder.initial_ch"] + return { + f"{NS}.bert.token_embd.weight": (h["text.vocab_size"], e), + f"{NS}.bert.position_embd.weight": (h["text.max_position"], e), + f"{NS}.bert.embd_proj.weight": (d, e), + f"{NS}.bert.layer.attn_q.weight": (d, d), + f"{NS}.bert.layer.ffn.weight": (ff, d), + f"{NS}.bert_encoder.weight": (hid, d), + f"{NS}.predictor.de.lstm0.weight_ih_l0": (2 * hid, hid + st), + f"{NS}.predictor.de.adaln0.fc.weight": (2 * hid, st), + f"{NS}.predictor.duration_proj.weight": (h["predictor.max_dur"], hid), + f"{NS}.predictor.F0.1.conv1.weight": (hid // 2, hid, 3), + f"{NS}.predictor.F0_proj.weight": (1, hid // 2, 1), + f"{NS}.text_encoder.embd.weight": (h["text.vocab_size"], hid), + f"{NS}.text_encoder.cnn.0.weight": (hid, hid, h["text_encoder.kernel"]), + f"{NS}.asr_proj.0.weight": (asr, hid), + f"{NS}.asr_proj.1.weight": (asr, asr), + f"{NS}.style.text": (st,), + f"{NS}.style.decoder": (sd_,), + f"{NS}.decoder.encode.conv1.weight": (W, asr + 2, 3), + f"{NS}.decoder.decode.0.conv1.weight": (W, W + h["decoder.asr_res_dim"] + 2, 3), + f"{NS}.decoder.decode.3.conv1.weight": (uic, W + h["decoder.asr_res_dim"] + 2, 3), + f"{NS}.decoder.asr_res.weight": (h["decoder.asr_res_dim"], asr, 1), + f"{NS}.decoder.encode.norm1.fc.weight": (2 * (asr + 2), sd_), + f"{NS}.gen.ups.0.weight": (uic, uic // 2, h["decoder.upsample_kernels"][0]), + f"{NS}.gen.ups.1.weight": (uic // 2, uic // 4, h["decoder.upsample_kernels"][1]), + f"{NS}.gen.noise_convs.0.weight": (uic // 2, h["decoder.istft_n_fft"] + 2, 2 * h["decoder.upsample_rates"][1]), + f"{NS}.gen.noise_convs.1.weight": (uic // 4, h["decoder.istft_n_fft"] + 2, 1), + f"{NS}.gen.conv_post.weight": (h["decoder.istft_n_fft"] + 2, uic // 4, 7), + f"{NS}.gen.m_source.l_linear.weight": (1, h["decoder.harmonic_num"] + 1), + } + + +def collect(sd_raw: dict, mapping: list[tuple[str, str]], skipped: tuple[str, ...], label: str) -> dict[str, np.ndarray]: + sd = fold_weight_norm(sd_raw) + out: dict[str, np.ndarray] = {} + used = set() + for src, dst in mapping: + if src not in sd: + raise SystemExit(f"{label}: missing tensor {src}") + t = sd[src].detach().cpu().contiguous() + if t.dtype.is_floating_point is False: + raise SystemExit(f"{label}: {src} is not floating point") + arr = t.float().numpy() + if dst.endswith(".style.text") or dst.endswith(".style.decoder"): + arr = arr.reshape(-1) + out[dst] = np.ascontiguousarray(arr, dtype=np.float32) + used.add(src) + leftover = sorted(set(sd) - used - set(skipped)) + if leftover: + raise SystemExit(f"{label}: unconsumed tensors: {leftover[:8]} ({len(leftover)} total)") + return out + + +def write_gguf(out_path: Path, tensors: dict[str, np.ndarray], vocab: list[tuple[str, int]], pins: dict, + outtype: str) -> None: + writer = gguf.GGUFWriter(str(out_path), arch=ARCH) + writer.add_string("general.name", "Paradee-8M v1.0") + writer.add_string("general.license", "apache-2.0") + writer.add_string("general.source.url", f"https://huggingface.co/{pins['model']['repo']}") + writer.add_string("general.base_model.0.name", "Kokoro-82M") + writer.add_string("general.description", + "Paradee-8M v1.0 single-voice (af_heart) TTS, distilled from Kokoro-82M. " + "StyleTTS-2 text side + iSTFTNet decoder + phase-locking filter.") + for key, val in HPARAMS.items(): + full = f"{NS}.{key}" + if isinstance(val, list): + writer.add_array(full, [int(x) for x in val]) + elif isinstance(val, float): + writer.add_float32(full, val) + else: + writer.add_int32(full, int(val)) + writer.add_string(f"{NS}.voice", "af_heart") + writer.add_string(f"{NS}.phoneme_format", "misaki") + writer.add_string(f"{NS}.input.pad_symbol", "$") + writer.add_array(f"{NS}.vocab.symbols", [s for s, _ in vocab]) + writer.add_array(f"{NS}.vocab.ids", [int(i) for _, i in vocab]) + + model = pins["model"] + writer.add_string(f"{NS}.pins.hf_repo", model["repo"]) + writer.add_string(f"{NS}.pins.hf_revision", model["revision"]) + writer.add_string(f"{NS}.pins.hf_tag", model["tag"]) + for rel in ("pytorch/text_side.pt", "pytorch/decoder.pt", "config.json", "tokenizer.json"): + writer.add_string(f"{NS}.pins.sha256.{rel}", pins["files"][rel]["sha256"]) + writer.add_string(f"{NS}.pins.source_repo", pins["source"]["repo"]) + writer.add_string(f"{NS}.pins.source_commit", pins["source"]["commit"]) + writer.add_string(f"{NS}.converter.git_blob_sha1", git_blob_sha1(Path(__file__))) + writer.add_string(f"{NS}.converter.outtype", outtype) + + for name in sorted(tensors): + arr = tensors[name] + if outtype == "f16" and arr.ndim >= 2 and not name.endswith((".style.text", ".style.decoder")): + arr = arr.astype(np.float16) + writer.add_tensor(name, arr) + + writer.write_header_to_file() + writer.write_kv_data_to_file() + writer.write_tensors_to_file() + writer.close() + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--model-dir", required=True, type=Path, + help="local snapshot of the pinned HF revision (pytorch/*.pt, config.json, tokenizer.json)") + ap.add_argument("--output", required=True, type=Path) + ap.add_argument("--outtype", choices=("f32", "f16"), default="f32", + help="f32 (default, the parity-gated file) or f16 for >=2-D weights " + "(experimental: fails test-paradee-parity, not pinned)") + ap.add_argument("--check-output", action="store_true", + help="fail unless the output sha256 equals the one pinned for this outtype") + args = ap.parse_args(argv) + + pins = json.loads(PINS_PATH.read_text(encoding="utf-8")) + check_pins(args.model_dir, pins) + vocab = load_vocab(args.model_dir) + + import torch + + text_sd = torch.load(args.model_dir / "pytorch/text_side.pt", map_location="cpu", weights_only=True) + dec_sd = torch.load(args.model_dir / "pytorch/decoder.pt", map_location="cpu", weights_only=True) + if set(text_sd) != {"model"} or set(dec_sd) != {"model"}: + raise SystemExit("expected {'model': state_dict} checkpoints") + text_sd, dec_sd = text_sd["model"], dec_sd["model"] + + tensors = collect(text_sd, text_side_map(text_sd), TEXT_SIDE_SKIPPED, "text_side.pt") + dec = collect(dec_sd, decoder_map(dec_sd), (), "decoder.pt") + overlap = set(tensors) & set(dec) + if overlap: + raise SystemExit(f"tensor name collision: {sorted(overlap)}") + tensors.update(dec) + + for name, shape in expected_shapes().items(): + if tuple(tensors[name].shape) != shape: + raise SystemExit(f"{name}: shape {tuple(tensors[name].shape)} != expected {shape}") + n_params = sum(int(a.size) for a in tensors.values()) + + args.output.parent.mkdir(parents=True, exist_ok=True) + write_gguf(args.output, tensors, vocab, pins, args.outtype) + digest = sha256_file(args.output) + print(f"wrote {args.output}: {len(tensors)} tensors, {n_params} parameters, " + f"{args.output.stat().st_size} bytes, sha256 {digest}") + + if args.check_output: + want = pins["gguf"][args.outtype]["sha256"] + if digest != want: + print(f"error: sha256 {digest} != pinned {want}", file=sys.stderr) + return 1 + print("output matches pins.json") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/paradee/pins.json b/tools/paradee/pins.json new file mode 100644 index 000000000..321e4bbdd --- /dev/null +++ b/tools/paradee/pins.json @@ -0,0 +1,93 @@ +{ + "model": { + "repo": "sahilmahendrakar/Paradee-8M-v1.0", + "revision": "c7f83f86c04e84d0ae94cbe1aad7618e0544f80a", + "tag": "v1.0", + "tag_commit": "8f34b01ef8adcb0bec470b89bf2fd62a7b2369e6", + "tag_note": "The v1.0 tag (8f34b01e) and the pinned main revision (c7f83f86) differ only in README.md; every model, config, tokenizer and sample blob is identical.", + "license": "Apache-2.0", + "params": 8070000 + }, + "files": { + "pytorch/text_side.pt": { + "size": 16998347, + "sha256": "0995bee746043e4f06f72be68fb8634530b95d6467b4a141759e721122648062" + }, + "pytorch/decoder.pt": { + "size": 15564135, + "sha256": "f231b9765ffdddd30df798530af0c557c06cb9cf029a475b8bfdca7b051b9d6c" + }, + "config.json": { + "size": 1774, + "sha256": "f24046974a3a8c747affefb45c7c504263a99d5081787908b16abe8f5ac94fcd" + }, + "tokenizer.json": { + "size": 3497, + "sha256": "77a02c8e164413299b4b4c403b14f8e0e1c1b727db4d46a09d6327b861060a34" + }, + "samples/sentences.txt": { + "size": 621, + "sha256": "2d411facb6b0429ad05f6ea3abf9c6a79ba6613990cadf54e2213e11a8c9fd1b" + }, + "samples/1_paradee.wav": { + "size": 468044, + "sha256": "5b4bf9265c0cb108d1d9c631c26a0c785aa1dd9672565fa680393fbb37133d7e" + }, + "samples/2_paradee.wav": { + "size": 283244, + "sha256": "2e40a0504a06f417d32761009eac1882fb122beb8e4a05e0b3e7374fb87d4fc1" + }, + "samples/3_paradee.wav": { + "size": 361244, + "sha256": "4aaf75652796ba650b9eb0b324a9f4c7e311ee5c74a888deda9ac0b90edf81d8" + }, + "samples/4_paradee.wav": { + "size": 457244, + "sha256": "374596ff98b2a61713c5521861edf6a2a65e460aebccfd9934029ceb9c1888df" + }, + "samples/5_paradee.wav": { + "size": 512444, + "sha256": "9164e9e329bf227cccc0bc75ec5f309c6295748ed799ea4775444ee66b940b1b" + } + }, + "source": { + "repo": "https://github.com/sahilmahendrakar/paradee", + "commit": "9c8b4d7504cbee7e64de2d0341bb690f0b0ab708", + "license": "Apache-2.0", + "files": { + "training/scripts/student.py": "90456f28ac3d6dd06e76ad8f253f9bcadd9060cdecb6880268651e0ff8ba0f8b", + "training/scripts/student_decoder.py": "2443f14fe8ff27be5702d88dc80f8c566e92c03273b6216825fa7787cd66b97c", + "training/scripts/phase_lock.py": "d502744ccb2ede9fc10f56d481020b368a414d4f35b964ffbace14bcd37bbe86", + "training/scripts/export_paradee.py": "5656e4eb725fdd97bb46fc00cbe824a1e9adac4bc4b46440ba27a5b8f326a4db", + "training/scripts/common.py": "76faab4b54f633c1dfee28bfb08c981d4adb3f4ee99b42271cf80a3fc0e9fc9c" + } + }, + "reference_env": { + "note": "training/uv.lock pins kokoro 0.9.4, misaki 0.9.4 and torch 2.14.0. spaCy, en_core_web_sm and espeak-ng are the versions used to generate tests/fixtures/sentences.misaki.json.", + "python": "3.12", + "torch": "2.14.0", + "kokoro": "0.9.4", + "misaki": "0.9.4", + "spacy": "3.8.16", + "en_core_web_sm": "3.8.0", + "espeak-ng": "1.52.0" + }, + "gguf": { + "f32": { + "file": "paradee-8m-v1.0-f32.gguf", + "size": 32072768, + "sha256": "9a18ae710211fab04129e00bc0cde04484c0cf8d80bb19e51df7e331ae83a254", + "note": "convert_paradee_pt_to_gguf.py --outtype f32 output; byte-reproducible (check with --check-output). Changes whenever the converter changes, because its git blob sha is in the metadata." + } + }, + "fixtures": { + "tests/fixtures/sentences.misaki.json": { + "sha256": "30ab9f9cd034b6bf8650e13af4728e658a7e57ccd75ff412c62a41bc4e42bc9d", + "made_by": "tools/dump_paradee_reference.py phonemes" + }, + "tests/fixtures/phase_lock_synthetic.f32": { + "sha256": "691b032dd084e0c9c28f363acef8fb3029880041c9e5a282452626733ef3ee34", + "made_by": "tools/dump_paradee_reference.py lock-fixture" + } + } +} From e5e470bc0d47996c8142cc0f03bd811fb94ba990 Mon Sep 17 00:00:00 2001 From: Shaw Date: Tue, 13 Oct 2026 23:00:58 -0400 Subject: [PATCH 2/3] tools/kokoro: read layer widths from the tensor shapes The predictor, decoder and generator had Kokoro-82M's widths as literals (hid 512 and style 128 in the predictor, 514/1090/1024/512 and style 128 in the decoder, 512 -> 256 -> 128 in the generator, asr width 512). They now come from the GGUF tensor shapes, so width-scaled students of Kokoro (such as Paradee-8M) run on the same code: - predictor_forward(ctx, PredictorConfig, ...) and decoder_forward(ctx, DecoderConfig, ...) take a tensor namespace and the student-only options: the asr_proj GELU MLP, the trained InstanceNorm affine inside AdaIN1d, torch.round durations, a separate predictor style, a double-precision harmonic source, a canonical source STFT (exact zeros below the DFT rounding floor), cancellation hooks and stage outputs. - generator_forward(..., GeneratorConfig, ...) replaces the literals; the old entry points keep Kokoro's defaults. - KokoroLayerOptions (opt-in, scoped): per-tap SGEMM convolutions on Accelerate, register-tiled NEON Conv1d/ConvTranspose1d on the portable path, and a polynomial Snake. test-kokoro-layer-options checks them against the default kernels. - The iSTFT evaluates its basis once instead of per sample (same values, same sums). Kokoro-82M is unchanged bit for bit: kokoro-tts output for three sentences at speed 1.0 and 1.3, with Accelerate and with KOKORO_FORCE_GGML, and the kokoro-stage-dump outputs compare equal byte for byte before and after. The predictor still reads its style at ref_s + 512 rather than ref_s + 128 (kokoro/model.py uses ref_s[:, 128:]). That is a separate fix with its own parity run; it is marked in kokoro_predictor_forward and left as it was. Co-Authored-By: Claude Opus 5.5 --- tools/kokoro/include/kokoro-decoder-front.h | 50 ++- tools/kokoro/include/kokoro-decoder.h | 33 ++ tools/kokoro/include/kokoro-generator.h | 81 +++- tools/kokoro/include/kokoro-layers.h | 254 ++++++++++++- tools/kokoro/include/kokoro-predictor.h | 82 +++- tools/kokoro/src/kokoro-decoder.cpp | 197 +++++++--- tools/kokoro/src/kokoro-generator.cpp | 282 ++++++++++---- tools/kokoro/src/kokoro-predictor.cpp | 356 ++++++++++++------ tools/kokoro/src/kokoro.cpp | 11 +- tools/kokoro/tests/CMakeLists.txt | 6 + .../tests/test_kokoro_layer_options.cpp | 123 ++++++ 11 files changed, 1193 insertions(+), 282 deletions(-) create mode 100644 tools/kokoro/tests/test_kokoro_layer_options.cpp diff --git a/tools/kokoro/include/kokoro-decoder-front.h b/tools/kokoro/include/kokoro-decoder-front.h index 4a89770c5..11244bba8 100644 --- a/tools/kokoro/include/kokoro-decoder-front.h +++ b/tools/kokoro/include/kokoro-decoder-front.h @@ -1,5 +1,9 @@ // SPDX-License-Identifier: MIT // kokoro-decoder-front.h — Decoder.forward up to the generator (validated port, #9588). +// +// Channel widths come from the weights struct (filled from the GGUF tensor +// shapes by kokoro-decoder.cpp), so width-scaled students of Kokoro-82M +// (Paradee-8M: AdaIN width 256, style 16) share this code. #pragma once #include #include @@ -24,6 +28,12 @@ struct DecAdainResBlk { const float * conv1x1_b = nullptr; // [Cout] (null — conv1x1 bias=False) const float * pool_w = nullptr; // [Cin, 1, 3] (upsample only) const float * pool_b = nullptr; // [Cin] (upsample only) + // Trained InstanceNorm affine inside AdaIN1d (students only; null keeps + // Kokoro-82M's identity affine). + const float * norm1_w = nullptr; // [Cin] + const float * norm1_b = nullptr; // [Cin] + const float * norm2_w = nullptr; // [Cout] + const float * norm2_b = nullptr; // [Cout] }; // AdainResBlk1d (decode-block flavor: leaky_relu 0.2; pool/shortcut; @@ -35,7 +45,7 @@ inline void dec_adainresblk1d_forward( // residual branch: norm1 -> leaky_relu(0.2) -> [pool] -> conv1 -> norm2 -> leaky_relu -> conv2 std::vector r(x, x + (size_t)Cin * T_in); - adain1d_forward(r.data(), Cin, T_in, s, Sdim, w.norm1_fc_w, w.norm1_fc_b); + adain1d_forward(r.data(), Cin, T_in, s, Sdim, w.norm1_fc_w, w.norm1_fc_b, w.norm1_w, w.norm1_b); for (size_t i = 0; i < r.size(); ++i) if (r[i] < 0) r[i] *= 0.2f; int T_pool = T_in; @@ -47,7 +57,7 @@ inline void dec_adainresblk1d_forward( } std::vector r3((size_t)Cout * T_pool); conv1d_forward(r.data(), Cin, T_pool, w.conv1_w, w.conv1_b, Cout, 3, 1, 1, 1, r3.data(), T_pool); - adain1d_forward(r3.data(), Cout, T_pool, s, Sdim, w.norm2_fc_w, w.norm2_fc_b); + adain1d_forward(r3.data(), Cout, T_pool, s, Sdim, w.norm2_fc_w, w.norm2_fc_b, w.norm2_w, w.norm2_b); for (size_t i = 0; i < r3.size(); ++i) if (r3[i] < 0) r3[i] *= 0.2f; std::vector r4((size_t)Cout * T_pool); conv1d_forward(r3.data(), Cout, T_pool, w.conv2_w, w.conv2_b, Cout, 3, 1, 1, 1, r4.data(), T_pool); @@ -82,15 +92,16 @@ struct DecoderFrontWeights { const float * F0_conv_b = nullptr; // [1] const float * N_conv_w = nullptr; // [1,1,3] const float * N_conv_b = nullptr; // [1] - const float * asr_res_w = nullptr; // [64,512,1] - const float * asr_res_b = nullptr; // [64] - DecAdainResBlk encode; // 514 -> 1024, learned_sc - DecAdainResBlk decode[4]; // 1090->1024 (x3), 1090->512 upsample + const float * asr_res_w = nullptr; // [asr_res_C, Cin_asr, 1] + const float * asr_res_b = nullptr; // [asr_res_C] + int asr_res_C = 64; // asr_res output channels (64 for Kokoro and Paradee) + DecAdainResBlk encode; // Kokoro: 514 -> 1024, learned_sc + DecAdainResBlk decode[4]; // Kokoro: 1090->1024 (x3), 1090->512 upsample }; // Decoder.forward up to (not including) the generator. -// asr[512,T_asr] (T_asr=132), F0_curve[2*T_asr], N[2*T_asr], s[128] -// Output: x_out [512, 2*T_asr] (== generator_in_0); also returns the +// asr[Cin_asr,T_asr], F0_curve[2*T_asr], N[2*T_asr], s[Sdim] +// Output: x_out [decode[3].Cout, 2*T_asr] (== generator_in_0); also returns the // stride-2 conv outputs F0_down[T_asr], N_down[T_asr] (caller passes them, // together with the ORIGINAL F0_curve, into the generator). inline void decoder_front( @@ -100,36 +111,37 @@ inline void decoder_front( std::vector & x_out, std::vector & F0_down, std::vector & N_down) { - const int Tc = 2 * T_asr; // 264 + const int Tc = 2 * T_asr; F0_down.assign(T_asr, 0.0f); conv1d_forward(F0_curve, 1, Tc, W.F0_conv_w, W.F0_conv_b, 1, 3, 2, 1, 1, F0_down.data(), T_asr); N_down.assign(T_asr, 0.0f); conv1d_forward(N_in, 1, Tc, W.N_conv_w, W.N_conv_b, 1, 3, 2, 1, 1, N_down.data(), T_asr); - // x = cat([asr, F0, N], dim=channels) -> [514, T_asr] + // x = cat([asr, F0, N], dim=channels) -> [Cin_asr + 2, T_asr] std::vector xcat((size_t)(Cin_asr + 2) * T_asr); std::memcpy(xcat.data(), asr, sizeof(float) * (size_t)Cin_asr * T_asr); std::memcpy(xcat.data() + (size_t)Cin_asr * T_asr, F0_down.data(), sizeof(float) * T_asr); std::memcpy(xcat.data() + (size_t)(Cin_asr + 1) * T_asr, N_down.data(), sizeof(float) * T_asr); std::vector x; int T_x; - dec_adainresblk1d_forward(W.encode, xcat.data(), T_asr, s, x, T_x); // encode 514->1024 + dec_adainresblk1d_forward(W.encode, xcat.data(), T_asr, s, x, T_x); // encode Cin_asr+2 -> W - std::vector asr_res((size_t)64 * T_asr); // asr_res Conv1d k1 512->64 - conv1d_forward(asr, Cin_asr, T_asr, W.asr_res_w, W.asr_res_b, 64, 1, 1, 0, 1, asr_res.data(), T_asr); + const int Cr = W.asr_res_C; + std::vector asr_res((size_t)Cr * T_asr); // asr_res Conv1d k1 Cin_asr->Cr + conv1d_forward(asr, Cin_asr, T_asr, W.asr_res_w, W.asr_res_b, Cr, 1, 1, 0, 1, asr_res.data(), T_asr); bool res = true; for (int b = 0; b < 4; ++b) { std::vector blk_in; - if (res) { // cat([x, asr_res, F0, N]) -> 1024+64+1+1 = 1090 + if (res) { // cat([x, asr_res, F0, N]) -> Kokoro 1024+64+1+1 = 1090 const int Cx = (int)(x.size() / T_x); - const int Cin_blk = Cx + 64 + 1 + 1; + const int Cin_blk = Cx + Cr + 1 + 1; blk_in.assign((size_t)Cin_blk * T_x, 0.0f); std::memcpy(blk_in.data(), x.data(), sizeof(float) * (size_t)Cx * T_x); - std::memcpy(blk_in.data() + (size_t)Cx * T_x, asr_res.data(), sizeof(float) * 64 * T_x); - std::memcpy(blk_in.data() + (size_t)(Cx + 64) * T_x, F0_down.data(), sizeof(float) * T_x); - std::memcpy(blk_in.data() + (size_t)(Cx + 65) * T_x, N_down.data(), sizeof(float) * T_x); + std::memcpy(blk_in.data() + (size_t)Cx * T_x, asr_res.data(), sizeof(float) * (size_t)Cr * T_x); + std::memcpy(blk_in.data() + (size_t)(Cx + Cr) * T_x, F0_down.data(), sizeof(float) * T_x); + std::memcpy(blk_in.data() + (size_t)(Cx + Cr + 1) * T_x, N_down.data(), sizeof(float) * T_x); } else { blk_in.assign(x.begin(), x.end()); } @@ -138,7 +150,7 @@ inline void decoder_front( x.swap(y); T_x = T_y; if (W.decode[b].upsample) res = false; // decode3 upsamples -> res stops } - x_out.swap(x); // [512, 264] + x_out.swap(x); // [decode[3].Cout, 2*T_asr] } } // namespace eliza_kokoro \ No newline at end of file diff --git a/tools/kokoro/include/kokoro-decoder.h b/tools/kokoro/include/kokoro-decoder.h index 3de5d2562..117d37341 100644 --- a/tools/kokoro/include/kokoro-decoder.h +++ b/tools/kokoro/include/kokoro-decoder.h @@ -5,16 +5,49 @@ // Wires the validated decoder_front (kokoro-decoder-front.h) + Generator // (kokoro-generator.h) against the model's all-F32 ggml context. Replaces the // J2-ship placeholder spectrogram in kokoro_synthesize (#9588). +// +// Every channel width is read from the GGUF tensor shapes, so the same code +// runs Kokoro-82M and width-scaled students of it (Paradee-8M). #pragma once #include #include +struct ggml_context; + namespace eliza_kokoro { struct kokoro_model; +struct DecoderConfig { + std::string ns = "kokoro"; // tensor namespace (".decoder.*", ".gen.*") + bool instnorm_affine = false; // AdaIN1d uses the trained InstanceNorm affine (normN.norm.*, adainN.j.norm.*) + bool canonical_source_stft = false; // see GeneratorConfig::canonical_source_stft + bool precise_source = false; // see GeneratorConfig::precise_source + int up_rates[2] = { 10, 6 }; // generator upsample rates (kernels and widths come from the tensors) + int istft_hop = 5; // generator iSTFT hop (n_fft comes from conv_post) + std::vector * har_source_out = nullptr; // generator m_source output + const std::vector * har_source_in = nullptr; // parity hook: replace m_source's output + bool (*cancel)(void * user) = nullptr; + void * cancel_user = nullptr; + bool cancelled = false; // out: set when cancel() stopped the forward +}; + +// Generic decoder over a loaded F32 context. +// asr_ct : [asr_dim, T_frame] channel-major +// F0, N : [2*T_frame] +// style : [Sdim] decoder style (Sdim read from the GGUF) +// Returns false on error (err set) or cancellation (cfg.cancelled, err "cancelled"). +bool decoder_forward( + ggml_context * ctx, + DecoderConfig & cfg, + const float * asr_ct, int asr_dim, int T_frame, + const float * F0, const float * N, + const float * style, + std::vector & audio, + std::string & err); + // Run the full decoder. Inputs come from kokoro_predictor_forward: // asr_ct : [512, T_frame] channel-major (transpose of PredictorOut.asr [T,512]) // F0, N : [2*T_frame] (PredictorOut.F0_pred / N_pred — the up-2x curves) diff --git a/tools/kokoro/include/kokoro-generator.h b/tools/kokoro/include/kokoro-generator.h index d1d88f696..86a733b71 100644 --- a/tools/kokoro/include/kokoro-generator.h +++ b/tools/kokoro/include/kokoro-generator.h @@ -2,15 +2,18 @@ // // kokoro-generator.h — iSTFTNet Generator.forward (StyleTTS-2 decoder back-end). // -// The generator turns the decoder body output `x` [512, 264], the style -// vector `s` [128], and the (un-downsampled) F0 curve `f0_curve` [264] into -// `audio` [79200] (24 kHz). +// The generator turns the decoder body output `x` [Cin, T0], the style +// vector `s` [Sdim], and the (un-downsampled) F0 curve `f0_curve` [T0] into +// `audio` [T0 * prod(upsample_rates) * hop] (24 kHz). +// +// Kokoro-82M: Cin 512, stage channels 256/128, Sdim 128. +// Paradee-8M: Cin 128, stage channels 64/32, Sdim 16. // // Weights are raw float pointers (PyTorch row-major, weight_norm-fused): // Conv1d weight [Cout, Cin, K] // ConvTranspose1d wt [Cin, Cout, K] // Linear weight [out, in] -// AdaIN1d fc.weight [2C, style_dim] (style_dim = 128) +// AdaIN1d fc.weight [2C, style_dim] // Snake alpha [C] // The caller supplies them via GeneratorWeights so the function composes with // any weight-loading boundary (GGUF tensor lookup, raw .f32 fixtures, …). @@ -28,12 +31,18 @@ struct GenSubBlockWeights { const float * conv1_b = nullptr; // [C] const float * conv2_w = nullptr; // [C, C, K] const float * conv2_b = nullptr; // [C] - const float * adain1_fc_w = nullptr; // [2C, 128] + const float * adain1_fc_w = nullptr; // [2C, Sdim] const float * adain1_fc_b = nullptr; // [2C] - const float * adain2_fc_w = nullptr; // [2C, 128] + const float * adain2_fc_w = nullptr; // [2C, Sdim] const float * adain2_fc_b = nullptr; // [2C] const float * alpha1 = nullptr; // [C] const float * alpha2 = nullptr; // [C] + // Trained InstanceNorm affine inside AdaIN1d (students only; null keeps + // Kokoro-82M's identity affine). + const float * adain1_norm_w = nullptr; // [C] + const float * adain1_norm_b = nullptr; // [C] + const float * adain2_norm_w = nullptr; // [C] + const float * adain2_norm_b = nullptr; // [C] }; struct GenAdaResBlockWeights { @@ -41,30 +50,78 @@ struct GenAdaResBlockWeights { }; struct GeneratorWeights { - // m_source.l_linear: Linear(9 -> 1). - const float * l_linear_w = nullptr; // [1, 9] + // m_source.l_linear: Linear(harmonics -> 1). + const float * l_linear_w = nullptr; // [1, harmonics] const float * l_linear_b = nullptr; // [1] // ups[0], ups[1]: ConvTranspose1d. weight [Cin, Cout, K], bias [Cout]. const float * ups_w[2] = { nullptr, nullptr }; const float * ups_b[2] = { nullptr, nullptr }; - // noise_convs[0], noise_convs[1]: Conv1d. weight [Cout, 22, K], bias [Cout]. + // noise_convs[0], noise_convs[1]: Conv1d. weight [Cout, n_fft+2, K], bias [Cout]. const float * noise_convs_w[2] = { nullptr, nullptr }; const float * noise_convs_b[2] = { nullptr, nullptr }; // noise_res[0] (k=7), noise_res[1] (k=11): AdaINResBlock1. GenAdaResBlockWeights noise_res[2]; - // resblocks[0..5]: AdaINResBlock1 (stage0: k=3,7,11 ch=256; stage1: ch=128). + // resblocks[0..5]: AdaINResBlock1 (3 kernels per stage, 2 stages). GenAdaResBlockWeights resblocks[6]; - // conv_post: Conv1d(128 -> 22, k=7, pad=3). weight [22, 128, 7], bias [22]. + // conv_post: Conv1d(ch[1] -> n_fft+2, k=7, pad=3). const float * conv_post_w = nullptr; const float * conv_post_b = nullptr; }; -// Generator.forward. audio is resized to T0 * 300 (== 79200 for T0=264). +// Generator shape and behaviour. The defaults are Kokoro-82M v1.0. +struct GeneratorConfig { + int Cin = 512; // input channels (upsample_initial_channel) + int ch[2] = { 256, 128 }; // channels after ups[0], ups[1] + int Sdim = 128; // style width + int up_rates[2] = { 10, 6 }; + int up_kernels[2] = { 20, 12 }; + int rb_kernels[3] = { 3, 7, 11 }; + int dil[3] = { 1, 3, 5 }; + int n_fft = 20; + int hop = 5; + int harmonics = 9; // harmonic_num + 1 + float sine_amp = 0.1f; + float voiced_threshold = 10.0f; + float sample_rate = 24000.0f; + + // Source STFT convention. false (Kokoro-82M as shipped): a direct double + // DFT, whose rounding noise decides the sign of near-zero imaginary parts + // and therefore the phase of near-empty bins. true (Paradee): imaginary + // parts and magnitudes below the DFT's rounding floor are set to exactly + // zero, so the DC/Nyquist bins of a real signal and the empty bins of a + // constant (unvoiced, noise-free) source get torch.stft's phase (0, or pi + // for a negative real part) instead of a sign picked by rounding noise. + bool canonical_source_stft = false; + // Harmonic source precision. false (Kokoro-82M as shipped): rad/phase + // pass through float between the SineGen steps. true (Paradee): every + // SineGen step runs in double and only sine_waves is rounded to float. + bool precise_source = false; + bool instnorm_affine = false; // documents that the AdaIN1d norm_* pointers are set + + // Optional outputs / hooks. + std::vector * har_source_out = nullptr; // m_source output [T0 * upsample_scale] + const std::vector * har_source_in = nullptr; // parity hook: use this source instead of m_source + bool (*cancel)(void * user) = nullptr; // checked between generator stages + void * cancel_user = nullptr; +}; + +// Generator.forward with an explicit configuration. Returns false only when +// `cfg.cancel` asked it to stop (audio is then cleared). +bool generator_forward( + const float * x_in, // [cfg.Cin, T0] channel-major + int T0, // input time (== 2 * predictor T_frame) + const float * s, // [cfg.Sdim] + const float * f0_curve, // [T0] + const GeneratorWeights & w, + const GeneratorConfig & cfg, + std::vector & audio); + +// Kokoro-82M Generator.forward. audio is resized to T0 * 300 (== 79200 for T0=264). void kokoro_generator_forward( const float * x_in, // [512, T0] channel-major int T0, // input time (== 2 * predictor T_frame) diff --git a/tools/kokoro/include/kokoro-layers.h b/tools/kokoro/include/kokoro-layers.h index b834c128c..398b560bf 100644 --- a/tools/kokoro/include/kokoro-layers.h +++ b/tools/kokoro/include/kokoro-layers.h @@ -384,6 +384,154 @@ inline void kokoro_axpy_f32(float a, const float * x, float * y, int n) { } // namespace detail #endif // KOKORO_USE_PORTABLE_FAST +// ================================================================= +// Opt-in layer strategies. Kokoro-82M never sets them, so its arithmetic is +// unchanged; Paradee enables them for its synthesis calls. +// tap_gemm_conv: stride-1 Conv1d on Accelerate as one SGEMM per kernel +// tap over a zero-padded input, instead of im2col (whose +// column matrix is Cin*K*T floats, ~70 MB for a 32-channel, +// K=11, 10 s generator stage). On the portable path, +// register-tiled Conv1d / ConvTranspose1d kernels instead of +// per-tap AXPY loops. +// fast_snake: Snake1D with a polynomial sin^2 (|error| < 5e-7) that +// vectorises, instead of one libm sin call per element. +// ================================================================= +namespace detail { +struct KokoroLayerOptions { + bool tap_gemm_conv = false; + bool fast_snake = false; +}; +inline KokoroLayerOptions & kokoro_layer_options() { + static thread_local KokoroLayerOptions options; + return options; +} + +class KokoroLayerOptionsScope { +public: + explicit KokoroLayerOptionsScope(const KokoroLayerOptions & o) : previous_(kokoro_layer_options()) { + kokoro_layer_options() = o; + } + ~KokoroLayerOptionsScope() { kokoro_layer_options() = previous_; } + KokoroLayerOptionsScope(const KokoroLayerOptionsScope &) = delete; + KokoroLayerOptionsScope & operator=(const KokoroLayerOptionsScope &) = delete; +private: + KokoroLayerOptions previous_; +}; + +// sin(y)^2 for float y: reduction by pi in double (sin^2 has period pi; a +// float reduction loses ~1e-7 per 100 rad), then an odd degree-11 +// polynomial for sin on [-pi/2, pi/2] in float. +inline float fast_sin2(float y) { + const double n = std::nearbyint((double) y * 0.318309886183790671538); + const float r = (float) ((double) y - n * 3.14159265358979323846); + const float r2 = r * r; + float p = -2.50521083854417187751e-08f; + p = p * r2 + 2.75573192239858906526e-06f; + p = p * r2 - 1.98412698412698412526e-04f; + p = p * r2 + 8.33333333333333321769e-03f; + p = p * r2 - 1.66666666666666657415e-01f; + const float sn = r + r * r2 * p; + return sn * sn; +} + +#if defined(KOKORO_USE_PORTABLE_FAST) && defined(KOKORO_USE_NEON) +#define KOKORO_HAVE_TILED_KERNELS 1 +// Register-tiled NEON kernels for the portable path (tap_gemm_conv). A tile +// is 4 output rows x 16 time steps held in 16 q-register accumulators across +// the whole reduction, with the 4 weights of each (ci, k) packed into one +// vector, so each 16-float input load feeds 64 FMAs (the AXPY path does one +// load, one FMA and one store per 4 MACs). Tiles run on the layer pool. +constexpr int KT_MR = 4; +constexpr int KT_NR = 16; + +// a: KT_MR-wide packed A columns (a[p*4 + r]), b: B rows (b + p*ldb), Kd steps. +inline void kt_micro(const float * a, const float * b, size_t ldb, int Kd, float32x4_t acc[KT_MR][4]) { + for (int p = 0; p < Kd; ++p) { + const float * bp = b + (size_t) p * ldb; + const float32x4_t x0 = vld1q_f32(bp), x1 = vld1q_f32(bp + 4), x2 = vld1q_f32(bp + 8), x3 = vld1q_f32(bp + 12); + const float32x4_t w = vld1q_f32(a + (size_t) p * KT_MR); +#define KT_ROW(r) \ + acc[r][0] = vfmaq_laneq_f32(acc[r][0], x0, w, r); acc[r][1] = vfmaq_laneq_f32(acc[r][1], x1, w, r); \ + acc[r][2] = vfmaq_laneq_f32(acc[r][2], x2, w, r); acc[r][3] = vfmaq_laneq_f32(acc[r][3], x3, w, r); + KT_ROW(0) KT_ROW(1) KT_ROW(2) KT_ROW(3) +#undef KT_ROW + } +} + +inline void kt_store(float32x4_t acc[KT_MR][4], float * c, size_t ldc, int mr, int nr) { + for (int r = 0; r < mr; ++r) { + float tmp[KT_NR]; + vst1q_f32(tmp, acc[r][0]); vst1q_f32(tmp + 4, acc[r][1]); + vst1q_f32(tmp + 8, acc[r][2]); vst1q_f32(tmp + 12, acc[r][3]); + std::memcpy(c + (size_t) r * ldc, tmp, sizeof(float) * (size_t) nr); + } +} + +// y[Cout, T_out] = b + sum_{ci,k} W[co, ci, k] * xp[ci, to + k*dil] +// xp: [Cin, ldx] zero-padded input, readable up to to + (K-1)*dil + KT_NR. +inline void conv1d_s1_tiled(const float * xp, int ldx, int Cin, + const float * W, const float * b, int Cout, int K, int dil, + float * y, int T_out) { + const int n_mb = (Cout + KT_MR - 1) / KT_MR; + const int n_nb = (T_out + KT_NR - 1) / KT_NR; + // Pack weights per row block: wp[mb][ci][k][r]. + std::vector wp((size_t) n_mb * Cin * K * KT_MR, 0.0f); + for (int co = 0; co < Cout; ++co) + for (int ci = 0; ci < Cin; ++ci) + for (int k = 0; k < K; ++k) + wp[(((size_t) (co / KT_MR) * Cin + ci) * K + k) * KT_MR + co % KT_MR] = + W[((size_t) co * Cin + ci) * K + k]; + const size_t total_macs = (size_t) Cout * T_out * Cin * K; + kokoro_parallel_for(total_macs, n_mb * n_nb, [&](int tile) { + const int mb = tile / n_nb; + const int co0 = mb * KT_MR; + const int t0 = (tile % n_nb) * KT_NR; + const int mr = std::min(KT_MR, Cout - co0); + const int nr = std::min(KT_NR, T_out - t0); + float32x4_t acc[KT_MR][4]; + for (int r = 0; r < KT_MR; ++r) { + const float32x4_t bv = vdupq_n_f32((b && r < mr) ? b[co0 + r] : 0.0f); + acc[r][0] = acc[r][1] = acc[r][2] = acc[r][3] = bv; + } + const float * wb = wp.data() + (size_t) mb * Cin * K * KT_MR; + for (int ci = 0; ci < Cin; ++ci) { + const float * xr = xp + (size_t) ci * ldx + t0; + if (dil == 1) { + // taps k are consecutive rows of a Hankel view: ldb = 1 + kt_micro(wb + (size_t) ci * K * KT_MR, xr, 1, K, acc); + } else { + kt_micro(wb + (size_t) ci * K * KT_MR, xr, (size_t) dil, K, acc); + } + } + kt_store(acc, y + (size_t) co0 * T_out + t0, (size_t) T_out, mr, nr); + }); +} + +// C[M, N] = A[M, Kd] (row-major, lda) * B[Kd, N] (row-major, ldb, readable +// to N rounded up to KT_NR). +inline void sgemm_tiled(int M, int N, int Kd, const float * A, int lda, const float * B, int ldb, + float * C, int ldc) { + const int n_mb = (M + KT_MR - 1) / KT_MR; + const int n_nb = (N + KT_NR - 1) / KT_NR; + std::vector ap((size_t) n_mb * Kd * KT_MR, 0.0f); + for (int i = 0; i < M; ++i) + for (int p = 0; p < Kd; ++p) + ap[((size_t) (i / KT_MR) * Kd + p) * KT_MR + i % KT_MR] = A[(size_t) i * lda + p]; + kokoro_parallel_for((size_t) M * N * Kd, n_mb * n_nb, [&](int tile) { + const int mb = tile / n_nb; + const int i0 = mb * KT_MR; + const int j0 = (tile % n_nb) * KT_NR; + const int mr = std::min(KT_MR, M - i0); + const int nr = std::min(KT_NR, N - j0); + float32x4_t acc[KT_MR][4]; + for (int r = 0; r < KT_MR; ++r) acc[r][0] = acc[r][1] = acc[r][2] = acc[r][3] = vdupq_n_f32(0.0f); + kt_micro(ap.data() + (size_t) mb * Kd * KT_MR, B + j0, (size_t) ldb, Kd, acc); + kt_store(acc, C + (size_t) i0 * ldc + j0, (size_t) ldc, mr, nr); + }); +} +#endif +} // namespace detail + // ================================================================= // Tensor (lightweight host tensor — view or owned) // ================================================================= @@ -452,6 +600,33 @@ inline void conv1d_forward( } #endif #if defined(KOKORO_USE_ACCELERATE) + if (detail::kokoro_layer_options().tap_gemm_conv && stride == 1 && K > 1) { + // y = sum_k W[:, :, k] · x_pad[:, k*dil : k*dil + T_out] + const int Tp = T + 2 * pad; + std::vector xp((size_t) Cin * Tp, 0.0f); + for (int ci = 0; ci < Cin; ++ci) { + std::memcpy(xp.data() + (size_t) ci * Tp + pad, x + (size_t) ci * T, sizeof(float) * (size_t) T); + } + std::vector wk((size_t) K * Cout * Cin); + for (int co = 0; co < Cout; ++co) + for (int ci = 0; ci < Cin; ++ci) + for (int k = 0; k < K; ++k) + wk[((size_t) k * Cout + co) * Cin + ci] = W[((size_t) co * Cin + ci) * K + k]; + if (b) { + for (int co = 0; co < Cout; ++co) { + float * yc = y + (size_t) co * T_out; + for (int to = 0; to < T_out; ++to) yc[to] = b[co]; + } + } + for (int k = 0; k < K; ++k) { + cblas_sgemm(CblasRowMajor, CblasNoTrans, CblasNoTrans, + Cout, T_out, Cin, + 1.0f, wk.data() + (size_t) k * Cout * Cin, Cin, + xp.data() + (size_t) k * dilation, Tp, + (b || k > 0) ? 1.0f : 0.0f, y, T_out); + } + return; + } // im2col + one SGEMM. The PyTorch weight layout [Cout, Cin, K] is already // a row-major [Cout, Cin*K] GEMM operand; the column matrix is // [Cin*K, T_out] with zero padding baked in, so @@ -491,6 +666,40 @@ inline void conv1d_forward( 1.0f, W, Cin * K, cols.data(), T_out, beta, y, T_out); #elif defined(KOKORO_USE_PORTABLE_FAST) +#if defined(KOKORO_HAVE_TILED_KERNELS) + if (detail::kokoro_layer_options().tap_gemm_conv && stride == 1) { + const int ldx = T + 2 * pad + detail::KT_NR; + std::vector xp((size_t) Cin * ldx, 0.0f); + for (int ci = 0; ci < Cin; ++ci) { + std::memcpy(xp.data() + (size_t) ci * ldx + pad, x + (size_t) ci * T, sizeof(float) * (size_t) T); + } + detail::conv1d_s1_tiled(xp.data(), ldx, Cin, W, b, Cout, K, dilation, y, T_out); + return; + } + if (detail::kokoro_layer_options().tap_gemm_conv && stride > 1) { + // Strided: im2col [Cin*K, T_out] (small for these shapes) + tiled GEMM. + const int ldc = T_out + detail::KT_NR; + std::vector cols((size_t) Cin * K * ldc, 0.0f); + for (int ci = 0; ci < Cin; ++ci) { + const float * xi = x + (size_t) ci * T; + for (int k = 0; k < K; ++k) { + float * col = cols.data() + ((size_t) ci * K + k) * ldc; + for (int to = 0; to < T_out; ++to) { + const int ti = to * stride + k * dilation - pad; + if (ti >= 0 && ti < T) col[to] = xi[ti]; + } + } + } + detail::sgemm_tiled(Cout, T_out, Cin * K, W, Cin * K, cols.data(), ldc, y, T_out); + if (b) { + for (int co = 0; co < Cout; ++co) { + float * yc = y + (size_t) co * T_out; + for (int to = 0; to < T_out; ++to) yc[to] += b[co]; + } + } + return; + } +#endif // Parallel over output channels (each thread owns disjoint y rows), and // per (ci, k) tap a branch-free AXPY over the valid `to` range — the // per-element bounds check of the reference loop is hoisted into the @@ -640,6 +849,32 @@ inline void convtranspose1d_forward( } } #elif defined(KOKORO_USE_PORTABLE_FAST) +#if defined(KOKORO_HAVE_TILED_KERNELS) + if (detail::kokoro_layer_options().tap_gemm_conv) { + // tmp[Cout*K, T] = W^T x, then col2im (as the Accelerate path). + std::vector wt((size_t) Cout * K * Cin); + for (int ci = 0; ci < Cin; ++ci) + for (int j = 0; j < Cout * K; ++j) + wt[(size_t) j * Cin + ci] = W[(size_t) ci * Cout * K + j]; + const int ldb = T + detail::KT_NR; + std::vector xb((size_t) Cin * ldb, 0.0f); + for (int ci = 0; ci < Cin; ++ci) std::memcpy(xb.data() + (size_t) ci * ldb, x + (size_t) ci * T, sizeof(float) * (size_t) T); + std::vector tmp((size_t) Cout * K * T); + detail::sgemm_tiled(Cout * K, T, Cin, wt.data(), Cin, xb.data(), ldb, tmp.data(), T); + for (int co = 0; co < Cout; ++co) { + float * yc = y + (size_t) co * T_out; + for (int k = 0; k < K; ++k) { + const float * row = tmp.data() + ((size_t) co * K + k) * T; + const int t_off = k - pad; + for (int t_in = 0; t_in < T; ++t_in) { + const int to = t_in * stride + t_off; + if (to >= 0 && to < T_out) yc[to] += row[t_in]; + } + } + } + return; + } +#endif // Parallel over output channels (each thread scatters into disjoint y // rows — no races). Two branch-free AXPY shapes, valid ranges hoisted // out of the inner loop: @@ -888,7 +1123,9 @@ inline void bilstm_forward( inline void adain1d_forward( float * x, int C, int T, const float * s, int Sdim, - const float * fc_W /* [2C, Sdim] */, const float * fc_b /* [2C] */) { + const float * fc_W /* [2C, Sdim] */, const float * fc_b /* [2C] */, + const float * norm_W = nullptr /* [C], InstanceNorm affine weight */, + const float * norm_b = nullptr /* [C], InstanceNorm affine bias */) { std::vector h(2 * C); linear_forward(s, Sdim, fc_W, fc_b, 2 * C, h.data()); // gamma = h[0..C], beta = h[C..2C] @@ -905,7 +1142,11 @@ inline void adain1d_forward( // saved state_dict shows no norm.weight key under norm1/norm2 of // AdainResBlk1d, only fc.weight/fc.bias. The default-affine // weight=1/bias=0 is correct then. - instance_norm1d_forward(x, C, T, 1e-5f, nullptr, nullptr); + // + // Models trained with the affine parameters live (Paradee's students) + // pass them as norm_W / norm_b; Kokoro-82M passes nullptr and keeps the + // identity affine, so its arithmetic is unchanged. + instance_norm1d_forward(x, C, T, 1e-5f, norm_W, norm_b); for (int c = 0; c < C; ++c) { const float gamma = h[c]; const float beta = h[C + c]; @@ -992,6 +1233,15 @@ struct AdainResBlk1dWeights { // Snake1D activation: x = x + (1/a) * sin(a*x)^2 // ================================================================= inline void snake1d_forward(float * x, int C, int T, const float * a /* [C] */) { + if (detail::kokoro_layer_options().fast_snake) { + for (int c = 0; c < C; ++c) { + const float av = std::abs(a[c]) > 1e-8f ? a[c] : 1e-8f; + const float inv_a = 1.0f / av; + float * xc = x + (size_t)c * T; + for (int t = 0; t < T; ++t) xc[t] = xc[t] + inv_a * detail::fast_sin2(av * xc[t]); + } + return; + } for (int c = 0; c < C; ++c) { const float av = std::abs(a[c]) > 1e-8f ? a[c] : 1e-8f; const float inv_a = 1.0f / av; diff --git a/tools/kokoro/include/kokoro-predictor.h b/tools/kokoro/include/kokoro-predictor.h index efea20d07..f28fce604 100644 --- a/tools/kokoro/include/kokoro-predictor.h +++ b/tools/kokoro/include/kokoro-predictor.h @@ -9,14 +9,21 @@ // numerically equivalent activations to the upstream Python reference. // // Stages (all forwarded by `kokoro_predictor_forward`): -// 1. Albert BERT (12 layers, num_hidden_groups=1) over input phoneme ids. -// 2. bert_encoder Linear 768→512 → d_en [T, 512]. +// 1. Albert BERT (n_layer, num_hidden_groups=1) over input phoneme ids. +// 2. bert_encoder Linear d_model→hid → d_en [T, hid]. // 3. predictor.text_encoder (DurationEncoder): 3 × (BiLSTM + AdaLayerNorm), -// conditioned on ref_s[:, 128:] (predictor-half style). -// Output `d` has shape [T, 512+128=640] (concat of LSTM output + style). -// 4. predictor.lstm BiLSTM over `d` → x [T, 512]. -// 5. duration_proj Linear (512→50) → duration logits → sigmoid → sum/speed +// conditioned on the predictor style. +// Output `d` has shape [T, hid+sty] (concat of LSTM output + style). +// 4. predictor.lstm BiLSTM over `d` → x [T, hid]. +// 5. duration_proj Linear (hid→max_dur) → duration logits → sigmoid → sum/speed // → per-phoneme float duration. round.clamp(min=1).long() → pred_dur. +// +// Widths come from the GGUF tensor shapes, so the same code runs Kokoro-82M +// (hid 512, style 128) and width-scaled students of it such as Paradee-8M +// (hid 192, style 32). `predictor_forward` takes a PredictorConfig for the +// student-only parts (tensor namespace, asr projection MLP, InstanceNorm +// affine, torch rounding, cancellation); `kokoro_predictor_forward` is the +// Kokoro-82M entry and keeps its arithmetic unchanged. #pragma once @@ -26,6 +33,8 @@ #include #include +struct ggml_context; + namespace eliza_kokoro { // Forward declaration — kokoro_model holds the actual tensor pointers. @@ -36,22 +45,69 @@ struct kokoro_model; struct PredictorOut { int T_phon = 0; // padded input length (BOS+phonemes+EOS) int T_frame = 0; // total frames after length-regulate (sum of pred_dur) + int hid = 0; // predictor hidden width (Kokoro 512) + int sty = 0; // predictor style width (Kokoro 128) + int asr_dim = 0; // width of `asr` rows (Kokoro 512 = hid) - std::vector d; // [T_phon, 640] = DurationEncoder output (LSTM cat style) - std::vector x_lstm; // [T_phon, 512] = predictor.lstm output (forward+reverse concat) + std::vector bert; // [T_phon, d_model] (only when PredictorConfig::keep_bert) + std::vector d; // [T_phon, hid+sty] = DurationEncoder output (LSTM cat style) + std::vector x_lstm; // [T_phon, hid] = predictor.lstm output (forward+reverse concat) std::vector duration; // [T_phon] = sigmoid+sum (pre-round, post-speed) std::vector pred_dur; // [T_phon] = round.clamp(1).long() // After length-regulate: - std::vector en; // [T_frame, 640] = d transposed and matmul'd with pred_aln_trg - std::vector t_en; // [T_phon, 512] = text_encoder forward (separate from BERT branch) - std::vector asr; // [T_frame, 512] = t_en @ pred_aln_trg + std::vector en; // [T_frame, hid+sty] = d transposed and matmul'd with pred_aln_trg + std::vector t_en; // [T_phon, hid] = text_encoder forward (separate from BERT branch) + std::vector asr; // [T_frame, asr_dim] = (asr_proj(t_en) or t_en) @ pred_aln_trg // F0 / N predictions (run via predictor.F0Ntrain on `en`). - std::vector F0_pred; // [T_frame] - std::vector N_pred; // [T_frame] + std::vector F0_pred; // [2*T_frame] + std::vector N_pred; // [2*T_frame] + + bool cancelled = false; // set when PredictorConfig::cancel stopped the forward +}; + +// Cancellation hook: return true to stop. Checked between stages. +using predictor_cancel_fn = bool (*)(void * user); + +struct PredictorConfig { + // Tensor namespace: ".bert.*", ".predictor.*", ... ("kokoro" for + // Kokoro-82M, "paradee" for Paradee-8M). + std::string ns = "kokoro"; + + // Albert text branch (from the GGUF hparams). + int text_n_layer = 12; + int text_n_head = 12; + int text_d_model = 768; + int text_d_ff = 2048; + + int n_layer = 3; // DurationEncoder / TextEncoder depth + int max_dur = 50; // duration_proj width + int text_kernel = 5; // TextEncoder conv kernel + + // Predictor style vector, [sty] floats (sty read from the GGUF). + const float * style = nullptr; + + // Student-only behaviour; all false reproduces Kokoro-82M exactly. + bool torch_round = false; // round half to even like torch.round (Kokoro: std::round) + bool instnorm_affine = false; // AdaIN1d applies the trained InstanceNorm affine (.normN.norm.*) + bool asr_proj_mlp = false; // asr = Linear(.asr_proj.1)(GELU(Linear(.asr_proj.0)(t_en))) + bool keep_bert = false; // keep the BERT output in PredictorOut::bert (stage dumps) + + predictor_cancel_fn cancel = nullptr; + void * cancel_user = nullptr; }; +// Generic predictor forward over a loaded F32 ggml context. Returns false on +// error (`err_out` set) or cancellation (`out.cancelled`, err_out "cancelled"). +bool predictor_forward( + ggml_context * ctx, + const PredictorConfig & cfg, + const std::vector & phoneme_ids, + float speed, + PredictorOut & out, + std::string & err_out); + // Run the predictor + text_encoder + length-regulation. `phoneme_ids` is the // raw phoneme sequence (BOS+phon+EOS); `ref_s` is the 256-dim style vector // (split internally into predictor + decoder halves). diff --git a/tools/kokoro/src/kokoro-decoder.cpp b/tools/kokoro/src/kokoro-decoder.cpp index a9a74402a..c3f7b5387 100644 --- a/tools/kokoro/src/kokoro-decoder.cpp +++ b/tools/kokoro/src/kokoro-decoder.cpp @@ -4,10 +4,14 @@ // StyleTTS-2 / iSTFTNet decoder, reading weights from the model's all-F32 // ggml context (dequantized at load). Validated against the PyTorch reference // stage-by-stage (#9588). +// +// Channel widths, kernel sizes and the style width are read from the tensor +// shapes (ggml ne[] is the reversed PyTorch shape), so Kokoro-82M and its +// width-scaled students share this file. #include "kokoro-decoder.h" #include "kokoro-decoder-front.h" // DecoderFrontWeights, DecAdainResBlk, decoder_front -#include "kokoro-generator.h" // GeneratorWeights, kokoro_generator_forward +#include "kokoro-generator.h" // GeneratorWeights, generator_forward #include "kokoro-model-internal.h" #include "kokoro-profile.h" @@ -21,16 +25,27 @@ namespace { struct Lk { ggml_context * ctx; - const float * get(const std::string & name) const { - ggml_tensor * t = kokoro_find_tensor_compat(ctx, name); + std::string ns; + std::string full(const std::string & rel) const { return ns + rel; } + ggml_tensor * tensor(const std::string & rel) const { + return kokoro_find_tensor_compat(ctx, full(rel)); + } + const float * get(const std::string & rel) const { + ggml_tensor * t = tensor(rel); return t ? (const float *) t->data : nullptr; } }; -// Fill an AdainResBlk1d (decode flavor) from a tensor-name prefix. -void fill_dec_block(const Lk & L, DecAdainResBlk & b, const std::string & pfx, - int Cin, int Cout, bool upsample) { - b.Cin = Cin; b.Cout = Cout; b.Sdim = 128; +// Fill an AdainResBlk1d (decode flavor) from a tensor-name prefix. Cin/Cout +// come from conv1.weight [Cout, Cin, 3] and Sdim from norm1.fc.weight +// [2*Cin, Sdim]. +bool fill_dec_block(const Lk & L, DecAdainResBlk & b, const std::string & pfx, + bool upsample, bool affine) { + ggml_tensor * c1 = L.tensor(pfx + ".conv1.weight"); + ggml_tensor * n1 = L.tensor(pfx + ".norm1.fc.weight"); + if (!c1 || !n1) return false; + const int Cin = (int) c1->ne[1], Cout = (int) c1->ne[2]; + b.Cin = Cin; b.Cout = Cout; b.Sdim = (int) n1->ne[0]; b.upsample = upsample; b.learned_sc = (Cin != Cout); b.norm1_fc_w = L.get(pfx + ".norm1.fc.weight"); @@ -45,10 +60,18 @@ void fill_dec_block(const Lk & L, DecAdainResBlk & b, const std::string & pfx, b.conv1x1_b = nullptr; // conv1x1 bias=False upstream b.pool_w = upsample ? L.get(pfx + ".pool.weight") : nullptr; b.pool_b = upsample ? L.get(pfx + ".pool.bias") : nullptr; + if (affine) { + b.norm1_w = L.get(pfx + ".norm1.norm.weight"); + b.norm1_b = L.get(pfx + ".norm1.norm.bias"); + b.norm2_w = L.get(pfx + ".norm2.norm.weight"); + b.norm2_b = L.get(pfx + ".norm2.norm.bias"); + if (!b.norm1_w || !b.norm1_b || !b.norm2_w || !b.norm2_b) return false; + } + return true; } // Fill an AdaINResBlock1 (generator flavor: 3 sub-blocks, Snake1D) from a prefix. -void fill_gen_block(const Lk & L, GenAdaResBlockWeights & b, const std::string & pfx) { +bool fill_gen_block(const Lk & L, GenAdaResBlockWeights & b, const std::string & pfx, bool affine) { for (int j = 0; j < 3; ++j) { const std::string js = std::to_string(j); GenSubBlockWeights & s = b.sub[j]; @@ -62,65 +85,116 @@ void fill_gen_block(const Lk & L, GenAdaResBlockWeights & b, const std::string & s.adain2_fc_b = L.get(pfx + ".adain2." + js + ".fc.bias"); s.alpha1 = L.get(pfx + ".alpha1." + js); s.alpha2 = L.get(pfx + ".alpha2." + js); + if (affine) { + s.adain1_norm_w = L.get(pfx + ".adain1." + js + ".norm.weight"); + s.adain1_norm_b = L.get(pfx + ".adain1." + js + ".norm.bias"); + s.adain2_norm_w = L.get(pfx + ".adain2." + js + ".norm.weight"); + s.adain2_norm_b = L.get(pfx + ".adain2." + js + ".norm.bias"); + if (!s.adain1_norm_w || !s.adain1_norm_b || !s.adain2_norm_w || !s.adain2_norm_b) return false; + } } + return true; } } // namespace -bool kokoro_decoder_forward( - const kokoro_model * model, - const float * asr_ct, int T_frame, +bool decoder_forward( + ggml_context * ctx, + DecoderConfig & cfg, + const float * asr_ct, int asr_dim, int T_frame, const float * F0, const float * N, - const float * ref_s_dec, + const float * style, std::vector & audio, std::string & err) { audio.clear(); - if (!model) { err = "null model"; return false; } - if (T_frame <= 0) { err = "non-positive T_frame"; return false; } - - ggml_context * ctx = kokoro_model_ggml_ctx(model); + cfg.cancelled = false; if (!ctx) { err = "null model context"; return false; } - Lk L{ctx}; + if (T_frame <= 0) { err = "non-positive T_frame"; return false; } + Lk L{ctx, cfg.ns}; + const bool affine = cfg.instnorm_affine; // --- decoder_front weights --- DecoderFrontWeights W; - W.F0_conv_w = L.get("kokoro.decoder.F0_conv.weight"); - W.F0_conv_b = L.get("kokoro.decoder.F0_conv.bias"); - W.N_conv_w = L.get("kokoro.decoder.N_conv.weight"); - W.N_conv_b = L.get("kokoro.decoder.N_conv.bias"); - W.asr_res_w = L.get("kokoro.decoder.asr_res.weight"); - W.asr_res_b = L.get("kokoro.decoder.asr_res.bias"); - fill_dec_block(L, W.encode, "kokoro.decoder.encode", 514, 1024, /*upsample*/false); - fill_dec_block(L, W.decode[0], "kokoro.decoder.decode.0", 1090, 1024, false); - fill_dec_block(L, W.decode[1], "kokoro.decoder.decode.1", 1090, 1024, false); - fill_dec_block(L, W.decode[2], "kokoro.decoder.decode.2", 1090, 1024, false); - fill_dec_block(L, W.decode[3], "kokoro.decoder.decode.3", 1090, 512, /*upsample*/true); - - if (!W.F0_conv_w || !W.asr_res_w || !W.encode.conv1_w || !W.decode[3].pool_w) { - err = "missing decoder weights (is the GGUF a full Kokoro model?)"; + W.F0_conv_w = L.get(".decoder.F0_conv.weight"); + W.F0_conv_b = L.get(".decoder.F0_conv.bias"); + W.N_conv_w = L.get(".decoder.N_conv.weight"); + W.N_conv_b = L.get(".decoder.N_conv.bias"); + W.asr_res_w = L.get(".decoder.asr_res.weight"); + W.asr_res_b = L.get(".decoder.asr_res.bias"); + ggml_tensor * asr_res_t = L.tensor(".decoder.asr_res.weight"); // [Cr, asr_dim, 1] + bool ok = asr_res_t != nullptr; + if (ok) W.asr_res_C = (int) asr_res_t->ne[2]; + ok = ok && fill_dec_block(L, W.encode, ".decoder.encode", /*upsample*/false, affine); + for (int b = 0; b < 4 && ok; ++b) { + ok = fill_dec_block(L, W.decode[b], ".decoder.decode." + std::to_string(b), + /*upsample*/ b == 3, affine); + } + if (!ok || !W.F0_conv_w || !W.asr_res_w || !W.encode.conv1_w || !W.decode[3].pool_w) { + err = "missing decoder weights (is the GGUF a full " + cfg.ns + " model?)"; + return false; + } + if (W.encode.Cin != asr_dim + 2 || (int) asr_res_t->ne[1] != asr_dim) { + err = "decoder input width " + std::to_string(W.encode.Cin - 2) + + " != asr width " + std::to_string(asr_dim); return false; } // --- generator weights --- GeneratorWeights G; - G.l_linear_w = L.get("kokoro.gen.m_source.l_linear.weight"); - G.l_linear_b = L.get("kokoro.gen.m_source.l_linear.bias"); - for (int i = 0; i < 2; ++i) { + G.l_linear_w = L.get(".gen.m_source.l_linear.weight"); + G.l_linear_b = L.get(".gen.m_source.l_linear.bias"); + for (int i = 0; i < 2 && ok; ++i) { const std::string is = std::to_string(i); - G.ups_w[i] = L.get("kokoro.gen.ups." + is + ".weight"); - G.ups_b[i] = L.get("kokoro.gen.ups." + is + ".bias"); - G.noise_convs_w[i] = L.get("kokoro.gen.noise_convs." + is + ".weight"); - G.noise_convs_b[i] = L.get("kokoro.gen.noise_convs." + is + ".bias"); - fill_gen_block(L, G.noise_res[i], "kokoro.gen.noise_res." + is); + G.ups_w[i] = L.get(".gen.ups." + is + ".weight"); + G.ups_b[i] = L.get(".gen.ups." + is + ".bias"); + G.noise_convs_w[i] = L.get(".gen.noise_convs." + is + ".weight"); + G.noise_convs_b[i] = L.get(".gen.noise_convs." + is + ".bias"); + ok = fill_gen_block(L, G.noise_res[i], ".gen.noise_res." + is, affine); } - for (int i = 0; i < 6; ++i) { - fill_gen_block(L, G.resblocks[i], "kokoro.gen.resblocks." + std::to_string(i)); + for (int i = 0; i < 6 && ok; ++i) { + ok = fill_gen_block(L, G.resblocks[i], ".gen.resblocks." + std::to_string(i), affine); + } + G.conv_post_w = L.get(".gen.conv_post.weight"); + G.conv_post_b = L.get(".gen.conv_post.bias"); + + ggml_tensor * ups0 = L.tensor(".gen.ups.0.weight"); // [Cin, ch0, K0] + ggml_tensor * ups1 = L.tensor(".gen.ups.1.weight"); // [ch0, ch1, K1] + ggml_tensor * post = L.tensor(".gen.conv_post.weight"); // [n_fft+2, ch1, 7] + ggml_tensor * lin = L.tensor(".gen.m_source.l_linear.weight"); // [1, harmonics] + if (!ok || !G.l_linear_w || !G.ups_w[0] || !G.conv_post_w || !G.resblocks[5].sub[2].conv2_w || + !ups0 || !ups1 || !post || !lin) { + err = "missing generator weights (is the GGUF a full " + cfg.ns + " model?)"; + return false; } - G.conv_post_w = L.get("kokoro.gen.conv_post.weight"); - G.conv_post_b = L.get("kokoro.gen.conv_post.bias"); - if (!G.l_linear_w || !G.ups_w[0] || !G.conv_post_w || !G.resblocks[5].sub[2].conv2_w) { - err = "missing generator weights (is the GGUF a full Kokoro model?)"; + GeneratorConfig gc; + gc.Cin = (int) ups0->ne[2]; + gc.ch[0] = (int) ups0->ne[1]; + gc.ch[1] = (int) ups1->ne[1]; + gc.up_kernels[0] = (int) ups0->ne[0]; + gc.up_kernels[1] = (int) ups1->ne[0]; + gc.up_rates[0] = cfg.up_rates[0]; + gc.up_rates[1] = cfg.up_rates[1]; + for (int j = 0; j < 3; ++j) { + ggml_tensor * rb = L.tensor(".gen.resblocks." + std::to_string(j) + ".convs1.0.weight"); + if (!rb) { err = "missing generator resblock weights"; return false; } + gc.rb_kernels[j] = (int) rb->ne[0]; + } + gc.n_fft = (int) post->ne[2] - 2; + gc.hop = cfg.istft_hop; + gc.harmonics = (int) lin->ne[0]; + ggml_tensor * gen_fc = L.tensor(".gen.resblocks.0.adain1.0.fc.weight"); // [2C, Sdim] + gc.Sdim = gen_fc ? (int) gen_fc->ne[0] : W.encode.Sdim; + gc.canonical_source_stft = cfg.canonical_source_stft; + gc.precise_source = cfg.precise_source; + gc.instnorm_affine = affine; + gc.har_source_out = cfg.har_source_out; + gc.har_source_in = cfg.har_source_in; + gc.cancel = cfg.cancel; + gc.cancel_user = cfg.cancel_user; + if (W.decode[3].Cout != gc.Cin || (int) ups1->ne[2] != gc.ch[0] || (int) post->ne[1] != gc.ch[1] || + gc.Sdim != W.encode.Sdim || gc.n_fft <= 0) { + err = "inconsistent decoder/generator tensor shapes"; return false; } @@ -128,19 +202,46 @@ bool kokoro_decoder_forward( std::vector x, F0_down, N_down; { kokoro_phase_timer t("decoder front"); - decoder_front(W, asr_ct, /*Cin_asr*/512, T_frame, F0, N, ref_s_dec, x, F0_down, N_down); + decoder_front(W, asr_ct, asr_dim, T_frame, F0, N, style, x, F0_down, N_down); + } + if (cfg.cancel && cfg.cancel(cfg.cancel_user)) { + cfg.cancelled = true; + err = "cancelled"; + return false; } const int T0 = 2 * T_frame; // decoder_front upsamples T_frame -> 2*T_frame - if ((int) (x.size() / 512) != T0) { + if ((int) (x.size() / (size_t) gc.Cin) != T0) { err = "decoder_front output width mismatch"; return false; } { kokoro_phase_timer t("generator (iSTFTNet)"); - kokoro_generator_forward(x.data(), T0, ref_s_dec, F0, G, audio); + if (!generator_forward(x.data(), T0, style, F0, G, gc, audio)) { + cfg.cancelled = true; + err = "cancelled"; + return false; + } } return true; } +bool kokoro_decoder_forward( + const kokoro_model * model, + const float * asr_ct, int T_frame, + const float * F0, const float * N, + const float * ref_s_dec, + std::vector & audio, + std::string & err) { + audio.clear(); + if (!model) { err = "null model"; return false; } + DecoderConfig cfg; // Kokoro-82M: namespace "kokoro", identity InstanceNorm affine, shipped source STFT + ggml_context * ctx = kokoro_model_ggml_ctx(model); + // asr width from asr_res.weight [64, asr_dim, 1] (512 for Kokoro-82M). + ggml_tensor * asr_res = kokoro_find_tensor_compat(ctx, "kokoro.decoder.asr_res.weight"); + if (!asr_res) { err = "missing decoder weights (is the GGUF a full kokoro model?)"; return false; } + return decoder_forward(ctx, cfg, asr_ct, (int) asr_res->ne[1], T_frame, + F0, N, ref_s_dec, audio, err); +} + } // namespace eliza_kokoro diff --git a/tools/kokoro/src/kokoro-generator.cpp b/tools/kokoro/src/kokoro-generator.cpp index 97d93d457..866542bfa 100644 --- a/tools/kokoro/src/kokoro-generator.cpp +++ b/tools/kokoro/src/kokoro-generator.cpp @@ -15,6 +15,10 @@ // - spec = exp(x[:11]), phase = sin(x[11:22]), // - inverse STFT (center=True) -> audio. // +// Widths, rates and the source convention come from GeneratorConfig; the +// defaults (and kokoro_generator_forward) are Kokoro v1.0. Paradee-8M runs the +// same code at Cin 128, stage channels 64/32, style 16. +// // Config (kokoro v1.0 istftnet): // style_dim=128, upsample_initial_channel=512, // upsample_rates=[10,6], upsample_kernel_sizes=[20,12], @@ -113,9 +117,53 @@ static void interp_linear(const float * x, int C, int T_in, int T_out, float * y // window. Output: mag[F, n_frames], phase[F, n_frames], F=n_fft/2+1, // n_frames = N/hop + 1. // ---------------------------------------------------------------------------- +// Canonical source STFT (GeneratorConfig::canonical_source_stft): the same +// centred DFT with a double-precision window and basis tables, then values +// at or below the frame's rounding floor (1e-12 * sum|x*w|) set to exactly 0 +// and torch.angle's convention for the phase (pi for a negative real value). +// A float-rounded window would leave ~1e-8 residue in the empty bins of a +// constant frame, above any useful floor, so the window here stays double. +static void stft_center_canonical(const std::vector & padded, int n_fft, int hop, int win, + int n_frames, std::vector & mag, std::vector & phase) { + const int F = n_fft / 2 + 1; + std::vector w((size_t) win), cs((size_t) F * win), sn((size_t) F * win); + for (int k = 0; k < win; ++k) w[(size_t) k] = 0.5 - 0.5 * std::cos(2.0 * K_PI * (double) k / (double) win); + for (int f = 0; f < F; ++f) { + for (int k = 0; k < win; ++k) { + const double ang = 2.0 * K_PI * (double) f * (double) k / (double) n_fft; + cs[(size_t) f * win + k] = std::cos(ang) * w[(size_t) k]; + sn[(size_t) f * win + k] = -std::sin(ang) * w[(size_t) k]; + } + } + std::vector x((size_t) win); + for (int t = 0; t < n_frames; ++t) { + const int off = t * hop; + double floor_abs = 0.0; + for (int k = 0; k < win; ++k) { + x[(size_t) k] = (double) padded[(size_t) (off + k)]; + floor_abs += std::fabs(x[(size_t) k] * w[(size_t) k]); + } + floor_abs *= 1e-12; + for (int f = 0; f < F; ++f) { + double re = 0.0, im = 0.0; + const double * c = cs.data() + (size_t) f * win; + const double * s = sn.data() + (size_t) f * win; + for (int k = 0; k < win; ++k) { + re += x[(size_t) k] * c[k]; + im += x[(size_t) k] * s[k]; + } + if (std::fabs(re) <= floor_abs) re = 0.0; + if (std::fabs(im) <= floor_abs) im = 0.0; + mag[(size_t) f * n_frames + t] = (float) std::sqrt(re * re + im * im); + phase[(size_t) f * n_frames + t] = + (im == 0.0 && re < 0.0) ? (float) K_PI : (float) std::atan2(im, re); + } + } +} + static void stft_center(const float * sig, int N, int n_fft, int hop, int win, std::vector & mag, std::vector & phase, - int & F, int & n_frames) { + int & F, int & n_frames, bool canonical) { F = n_fft / 2 + 1; const int pad = n_fft / 2; const int Np = N + 2 * pad; @@ -130,6 +178,10 @@ static void stft_center(const float * sig, int N, int n_fft, int hop, int win, const std::vector window = hann_periodic(win); mag.assign((size_t) F * n_frames, 0.0f); phase.assign((size_t) F * n_frames, 0.0f); + if (canonical) { + stft_center_canonical(padded, n_fft, hop, win, n_frames, mag, phase); + return; + } for (int t = 0; t < n_frames; ++t) { const int off = t * hop; for (int f = 0; f < F; ++f) { @@ -141,8 +193,10 @@ static void stft_center(const float * sig, int N, int n_fft, int hop, int win, re += v * std::cos(ang); im += v * std::sin(ang); } - mag[(size_t) f * n_frames + t] = (float) std::sqrt(re * re + im * im); - phase[(size_t) f * n_frames + t] = (float) std::atan2(im, re); + { + mag[(size_t) f * n_frames + t] = (float) std::sqrt(re * re + im * im); + phase[(size_t) f * n_frames + t] = (float) std::atan2(im, re); + } } } } @@ -163,17 +217,32 @@ static void istft_center(const float * mag, const float * phase, const std::vector window = hann_periodic(win); std::vector frame((size_t) n_fft); + // Basis tables and per-frame (re, im): the same double values the + // straightforward triple loop computes, evaluated once instead of n_fft + // times, so the sums (and their order) are unchanged. + std::vector cos_t((size_t) F * n_fft), sin_t((size_t) F * n_fft); + for (int f = 0; f < F; ++f) { + for (int n = 0; n < n_fft; ++n) { + const double ang = 2.0 * K_PI * (double) f * (double) n / (double) n_fft; + cos_t[(size_t) f * n_fft + n] = std::cos(ang); + sin_t[(size_t) f * n_fft + n] = std::sin(ang); + } + } + std::vector re_f((size_t) F), im_f((size_t) F); for (int t = 0; t < n_frames; ++t) { + for (int f = 0; f < F; ++f) { + const double m = mag[(size_t) f * n_frames + t]; + const double p = phase[(size_t) f * n_frames + t]; + re_f[(size_t) f] = m * std::cos(p); + im_f[(size_t) f] = m * std::sin(p); + } // irfft of the hermitian spectrum -> n_fft real samples. for (int n = 0; n < n_fft; ++n) { double s = 0.0; for (int f = 0; f < F; ++f) { - const double m = mag[(size_t) f * n_frames + t]; - const double p = phase[(size_t) f * n_frames + t]; - const double re = m * std::cos(p); - const double im = m * std::sin(p); - const double ang = 2.0 * K_PI * (double) f * (double) n / (double) n_fft; - double term = re * std::cos(ang) - im * std::sin(ang); + const double re = re_f[(size_t) f]; + const double im = im_f[(size_t) f]; + double term = re * cos_t[(size_t) f * n_fft + n] - im * sin_t[(size_t) f * n_fft + n]; if (f != 0 && !(n_fft % 2 == 0 && f == F - 1)) term *= 2.0; s += term; } @@ -203,6 +272,9 @@ static void istft_center(const float * mag, const float * phase, // ---------------------------------------------------------------------------- static int get_padding(int k, int d) { return (k * d - d) / 2; } +// Length after F.interpolate(scale_factor = 1 / upsample_scale). +static int Lds_of(int L, int upsample_scale) { return L / upsample_scale; } + static void adain_resblock1(const GenAdaResBlockWeights & w, float * x, int C, int T, const float * s, int Sdim, int K, const int dil[3]) { @@ -210,12 +282,14 @@ static void adain_resblock1(const GenAdaResBlockWeights & w, for (int i = 0; i < 3; ++i) { const GenSubBlockWeights & sb = w.sub[i]; std::memcpy(xt.data(), x, sizeof(float) * (size_t) C * T); - adain1d_forward(xt.data(), C, T, s, Sdim, sb.adain1_fc_w, sb.adain1_fc_b); + adain1d_forward(xt.data(), C, T, s, Sdim, sb.adain1_fc_w, sb.adain1_fc_b, + sb.adain1_norm_w, sb.adain1_norm_b); snake1d_forward(xt.data(), C, T, sb.alpha1); const int d1 = dil[i]; conv1d_forward(xt.data(), C, T, sb.conv1_w, sb.conv1_b, C, K, 1, get_padding(K, d1), d1, y1.data(), T); - adain1d_forward(y1.data(), C, T, s, Sdim, sb.adain2_fc_w, sb.adain2_fc_b); + adain1d_forward(y1.data(), C, T, s, Sdim, sb.adain2_fc_w, sb.adain2_fc_b, + sb.adain2_norm_w, sb.adain2_norm_b); snake1d_forward(y1.data(), C, T, sb.alpha2); conv1d_forward(y1.data(), C, T, sb.conv2_w, sb.conv2_b, C, K, 1, get_padding(K, 1), 1, y2.data(), T); @@ -226,25 +300,31 @@ static void adain_resblock1(const GenAdaResBlockWeights & w, } // namespace // ============================================================================ -// kokoro_generator_forward +// generator_forward // ============================================================================ -void kokoro_generator_forward( - const float * x_in, // [512, T0] +bool generator_forward( + const float * x_in, // [cfg.Cin, T0] int T0, // input time (== 2 * predictor T_frame) - const float * s, // [128] + const float * s, // [cfg.Sdim] const float * f0_curve, // [T0] const GeneratorWeights & w, - std::vector & audio /* out [T0 * 300] */) { - - const int Sdim = 128; - const int up0 = 10, up1 = 6; // upsample rates - const int hop = 5; - const int n_fft = 20, win = 20; - const int upsample_scale = up0 * up1 * hop; // 300 - const int dim = 9; // harmonic_num (8) + fundamental - const float sine_amp = 0.1f; - const float voiced_threshold = 10.0f; - const float sr = 24000.0f; + const GeneratorConfig & cfg, + std::vector & audio /* out [T0 * upsample_scale] */) { + + const int Sdim = cfg.Sdim; + const int up0 = cfg.up_rates[0], up1 = cfg.up_rates[1]; // upsample rates + const int hop = cfg.hop; + const int n_fft = cfg.n_fft, win = cfg.n_fft; + const int upsample_scale = up0 * up1 * hop; // Kokoro: 300 + const int dim = cfg.harmonics; // harmonic_num + fundamental (Kokoro: 9) + const float sine_amp = cfg.sine_amp; + const float voiced_threshold = cfg.voiced_threshold; + const float sr = cfg.sample_rate; + const auto cancelled = [&]() { + if (!cfg.cancel || !cfg.cancel(cfg.cancel_user)) return false; + audio.clear(); + return true; + }; // Phase checkpoints (KOKORO_PROFILE=1): logs the delta since the previous // mark, attributing generator time to source/stft, the two upsample @@ -267,45 +347,83 @@ void kokoro_generator_forward( for (int r = 0; r < upsample_scale; ++r) f0_up[(size_t) t * upsample_scale + r] = f0_curve[t]; - // fn = f0 * [1..9]; rad = (fn / sr) % 1 ; [dim, L] channel-major. - std::vector rad((size_t) dim * L); - for (int h = 0; h < dim; ++h) { - const float mul = (float) (h + 1); - for (int t = 0; t < L; ++t) { - float v = f0_up[(size_t) t] * mul / sr; - v = v - std::floor(v); // % 1 - rad[(size_t) h * L + t] = v; + std::vector sine_waves((size_t) dim * L); + if (!cfg.precise_source) { + // fn = f0 * [1..9]; rad = (fn / sr) % 1 ; [dim, L] channel-major. + std::vector rad((size_t) dim * L); + for (int h = 0; h < dim; ++h) { + const float mul = (float) (h + 1); + for (int t = 0; t < L; ++t) { + float v = f0_up[(size_t) t] * mul / sr; + v = v - std::floor(v); // % 1 + rad[(size_t) h * L + t] = v; + } } - } - // Deterministic: rad[:,0,:] += 0 (no random initial phase). - // _f02sine: downsample rad by 1/upsample_scale (linear), cumsum*2pi, - // upsample by *upsample_scale (with phase scaled by upsample_scale), sin. - const int Lds = L / upsample_scale; // 264 - std::vector rad_ds((size_t) dim * Lds); - interp_linear(rad.data(), dim, L, Lds, rad_ds.data()); - // cumsum over time per channel, * 2*pi. - std::vector phase_ds((size_t) dim * Lds); - for (int h = 0; h < dim; ++h) { - double cum = 0.0; - for (int t = 0; t < Lds; ++t) { - cum += (double) rad_ds[(size_t) h * Lds + t]; - phase_ds[(size_t) h * Lds + t] = (float) (cum * 2.0 * K_PI); + // Deterministic: rad[:,0,:] += 0 (no random initial phase). + // _f02sine: downsample rad by 1/upsample_scale (linear), cumsum*2pi, + // upsample by *upsample_scale (with phase scaled by upsample_scale), sin. + const int Lds = L / upsample_scale; // 264 + std::vector rad_ds((size_t) dim * Lds); + interp_linear(rad.data(), dim, L, Lds, rad_ds.data()); + // cumsum over time per channel, * 2*pi. + std::vector phase_ds((size_t) dim * Lds); + for (int h = 0; h < dim; ++h) { + double cum = 0.0; + for (int t = 0; t < Lds; ++t) { + cum += (double) rad_ds[(size_t) h * Lds + t]; + phase_ds[(size_t) h * Lds + t] = (float) (cum * 2.0 * K_PI); + } } - } - // F.interpolate(phase * upsample_scale, scale=upsample_scale, linear). - std::vector phase_scaled((size_t) dim * Lds); - for (size_t i = 0; i < (size_t) dim * Lds; ++i) - phase_scaled[i] = phase_ds[i] * (float) upsample_scale; - std::vector phase_up((size_t) dim * L); - interp_linear(phase_scaled.data(), dim, Lds, L, phase_up.data()); - // sines = sin(phase) * sine_amp ; voiced mask uv = (f0 > thr). - // sine_waves = sines * uv (+ noise = 0). sine_merge = tanh(l_linear(sine_waves)). - std::vector sine_waves((size_t) dim * L); - for (int h = 0; h < dim; ++h) { - for (int t = 0; t < L; ++t) { - const float uv = (f0_up[(size_t) t] > voiced_threshold) ? 1.0f : 0.0f; - sine_waves[(size_t) h * L + t] = - std::sin(phase_up[(size_t) h * L + t]) * sine_amp * uv; + // F.interpolate(phase * upsample_scale, scale=upsample_scale, linear). + std::vector phase_scaled((size_t) dim * Lds); + for (size_t i = 0; i < (size_t) dim * Lds; ++i) + phase_scaled[i] = phase_ds[i] * (float) upsample_scale; + std::vector phase_up((size_t) dim * L); + interp_linear(phase_scaled.data(), dim, Lds, L, phase_up.data()); + // sines = sin(phase) * sine_amp ; voiced mask uv = (f0 > thr). + // sine_waves = sines * uv (+ noise = 0). sine_merge = tanh(l_linear(sine_waves)). + for (int h = 0; h < dim; ++h) { + for (int t = 0; t < L; ++t) { + const float uv = (f0_up[(size_t) t] > voiced_threshold) ? 1.0f : 0.0f; + sine_waves[(size_t) h * L + t] = + std::sin(phase_up[(size_t) h * L + t]) * sine_amp * uv; + } + } + } else { + // The same SineGen._f02sine steps in double, so the phase of the top + // harmonic (~1e5 rad after a few seconds) keeps sub-microradian + // precision; values are rounded to float only as sine_waves. + const double scale_dn = (double) L / (double) Lds_of(L, upsample_scale); + const int Lds = Lds_of(L, upsample_scale); + const double scale_up = (double) Lds / (double) L; + std::vector phase_ds((size_t) Lds); + for (int h = 0; h < dim; ++h) { + const double mul = (double) (h + 1); + auto rad_at = [&](int t) { + double v = (double) f0_up[(size_t) t] * mul / (double) sr; + return v - std::floor(v); // % 1 + }; + double cum = 0.0; + for (int o = 0; o < Lds; ++o) { + double src = ((double) o + 0.5) * scale_dn - 0.5; + if (src < 0.0) src = 0.0; + int s0 = std::min((int) src, L - 1); + const int s1 = s0 < L - 1 ? s0 + 1 : s0; + const double frac = std::min(std::max(src - (double) s0, 0.0), 1.0); + const double r0 = rad_at(s0), r1 = rad_at(s1); + cum += r0 * (1.0 - frac) + r1 * frac; + phase_ds[(size_t) o] = cum * 2.0 * K_PI * (double) upsample_scale; + } + for (int t = 0; t < L; ++t) { + double src = ((double) t + 0.5) * scale_up - 0.5; + if (src < 0.0) src = 0.0; + int s0 = std::min((int) src, Lds - 1); + const int s1 = s0 < Lds - 1 ? s0 + 1 : s0; + const double frac = std::min(std::max(src - (double) s0, 0.0), 1.0); + const double ph = phase_ds[(size_t) s0] * (1.0 - frac) + phase_ds[(size_t) s1] * frac; + const double uv = ((double) f0_up[(size_t) t] > (double) voiced_threshold) ? 1.0 : 0.0; + sine_waves[(size_t) h * L + t] = (float) (std::sin(ph) * (double) sine_amp * uv); + } } } // l_linear: Linear(9 -> 1). weight [1, 9], bias [1]. har_source[L]. @@ -316,14 +434,20 @@ void kokoro_generator_forward( acc += (double) w.l_linear_w[h] * (double) sine_waves[(size_t) h * L + t]; har_source[(size_t) t] = std::tanh((float) acc); } + if (cfg.har_source_in && cfg.har_source_in->size() == har_source.size()) { + har_source = *cfg.har_source_in; + } DBG("har_source", har_source.data(), har_source.size()); + if (cfg.har_source_out) *cfg.har_source_out = har_source; + if (cancelled()) return false; // ------------------------------------------------------------------ // 2. STFT of har_source -> har = cat(mag, phase) [22, n_frames]. // ------------------------------------------------------------------ std::vector hmag, hphase; int F = 0, n_frames = 0; - stft_center(har_source.data(), L, n_fft, hop, win, hmag, hphase, F, n_frames); + stft_center(har_source.data(), L, n_fft, hop, win, hmag, hphase, F, n_frames, + cfg.canonical_source_stft); const int Hc = 2 * F; // 22 std::vector har((size_t) Hc * n_frames); std::memcpy(har.data(), hmag.data(), sizeof(float) * (size_t) F * n_frames); @@ -343,15 +467,16 @@ void kokoro_generator_forward( // 3. Upsample stages. // ------------------------------------------------------------------ const int ups_rate[2] = { up0, up1 }; - const int ups_k[2] = { 20, 12 }; - const int ch_after[2] = { 256, 128 }; // upsample_initial_channel >> (i+1) - const int dil135[3] = { 1, 3, 5 }; + const int ups_k[2] = { cfg.up_kernels[0], cfg.up_kernels[1] }; + const int ch_after[2] = { cfg.ch[0], cfg.ch[1] }; // upsample_initial_channel >> (i+1) + const int dil135[3] = { cfg.dil[0], cfg.dil[1], cfg.dil[2] }; - // current x: [512, 264] (copy, mutable). - int curC = 512, curT = T0; + // current x: [Cin, T0] (copy, mutable). + int curC = cfg.Cin, curT = T0; std::vector x(x_in, x_in + (size_t) curC * curT); for (int i = 0; i < 2; ++i) { + if (cancelled()) return false; const int u = ups_rate[i], k = ups_k[i]; const int outC = ch_after[i]; @@ -413,8 +538,9 @@ void kokoro_generator_forward( // resblocks: xs_sum = sum_j resblock[i*3+j](x, s) / 3. std::vector accum((size_t) outC * xT, 0.0f); - const int rb_k[3] = { 3, 7, 11 }; + const int rb_k[3] = { cfg.rb_kernels[0], cfg.rb_kernels[1], cfg.rb_kernels[2] }; for (int j = 0; j < 3; ++j) { + if (cancelled()) return false; std::vector rb(xs); // copy current x adain_resblock1(w.resblocks[i * 3 + j], rb.data(), outC, xT, s, Sdim, rb_k[j], dil135); @@ -431,6 +557,7 @@ void kokoro_generator_forward( // ------------------------------------------------------------------ // 4. conv_post -> spec/phase -> iSTFT. // ------------------------------------------------------------------ + if (cancelled()) return false; leaky_relu(x.data(), curC * curT, 0.01f); // F.leaky_relu (no slope arg) = default 0.01 const int cpC = n_fft + 2; // 22 std::vector cp((size_t) cpC * curT); @@ -449,6 +576,21 @@ void kokoro_generator_forward( } istft_center(spec.data(), phase.data(), n_fft, hop, win, curT, audio); prof_mark(" gen: post+istft"); + return true; +} + +// ============================================================================ +// kokoro_generator_forward (Kokoro-82M defaults) +// ============================================================================ +void kokoro_generator_forward( + const float * x_in, // [512, T0] + int T0, // input time (== 2 * predictor T_frame) + const float * s, // [128] + const float * f0_curve, // [T0] + const GeneratorWeights & w, + std::vector & audio /* out [T0 * 300] */) { + GeneratorConfig cfg; + generator_forward(x_in, T0, s, f0_curve, w, cfg, audio); } } // namespace eliza_kokoro diff --git a/tools/kokoro/src/kokoro-predictor.cpp b/tools/kokoro/src/kokoro-predictor.cpp index a32ded1a7..e23273f55 100644 --- a/tools/kokoro/src/kokoro-predictor.cpp +++ b/tools/kokoro/src/kokoro-predictor.cpp @@ -43,7 +43,17 @@ namespace { // Tensor lookup helper. struct TLookup { ggml_context * ctx = nullptr; - const float * get(const std::string & name, std::string * err = nullptr) const { + std::string ns = "kokoro"; + // Expand a namespace-relative name: ".bert.x" -> ".bert.x". Names that + // do not start with '.' are used as given. + std::string full(const std::string & name) const { + return (!name.empty() && name[0] == '.') ? ns + name : name; + } + ggml_tensor * tensor(const std::string & name) const { + return kokoro_find_tensor_compat(ctx, full(name)); + } + const float * get(const std::string & rel, std::string * err = nullptr) const { + const std::string name = full(rel); ggml_tensor * t = kokoro_find_tensor_compat(ctx, name); if (!t) { if (err) *err = "missing tensor '" + name + "'"; @@ -51,12 +61,12 @@ struct TLookup { } return (const float *) t->data; } - const float * get_or_null(const std::string & name) const { - ggml_tensor * t = kokoro_find_tensor_compat(ctx, name); + const float * get_or_null(const std::string & rel) const { + ggml_tensor * t = kokoro_find_tensor_compat(ctx, full(rel)); return t ? (const float *) t->data : nullptr; } - bool has(const std::string & name) const { - return kokoro_find_tensor_compat(ctx, name) != nullptr; + bool has(const std::string & rel) const { + return kokoro_find_tensor_compat(ctx, full(rel)) != nullptr; } }; @@ -96,19 +106,22 @@ static void bert_forward( std::vector & out /* [N, d_model] */, std::string & err) { const int N = (int) ids.size(); - const int d_embd = 128; + // Factorized ALBERT embedding width (128 for Kokoro-82M and Paradee-8M), + // read from the token embedding [vocab, d_embd] (ggml ne[0] = d_embd). + ggml_tensor * tok_t = T.tensor(".bert.token_embd.weight"); + const int d_embd = tok_t ? (int) tok_t->ne[0] : 128; const int head_dim = d_model / n_head; - const float * tok_embd = T.get("kokoro.bert.token_embd.weight", &err); // [vocab, 128] - const float * pos_embd = T.get("kokoro.bert.position_embd.weight", &err); // [maxpos, 128] - const float * tt_embd = T.get("kokoro.bert.tok_type_embd.weight", &err); // [2, 128] - const float * ln_w = T.get("kokoro.bert.embd_ln.weight", &err); - const float * ln_b = T.get("kokoro.bert.embd_ln.bias", &err); - const float * proj_w = T.get("kokoro.bert.embd_proj.weight", &err); // [768, 128] - const float * proj_b = T.get("kokoro.bert.embd_proj.bias", &err); + const float * tok_embd = T.get(".bert.token_embd.weight", &err); // [vocab, 128] + const float * pos_embd = T.get(".bert.position_embd.weight", &err); // [maxpos, 128] + const float * tt_embd = T.get(".bert.tok_type_embd.weight", &err); // [2, 128] + const float * ln_w = T.get(".bert.embd_ln.weight", &err); + const float * ln_b = T.get(".bert.embd_ln.bias", &err); + const float * proj_w = T.get(".bert.embd_proj.weight", &err); // [768, 128] + const float * proj_b = T.get(".bert.embd_proj.bias", &err); if (!tok_embd || !pos_embd || !tt_embd || !ln_w || !ln_b || !proj_w || !proj_b) return; - // Step 1+2+3: embed → LN(128) → project to 768. + // Step 1+2+3: embed → LN(d_embd) → project to d_model. std::vector h(N * d_embd, 0.0f); for (int t = 0; t < N; ++t) { const int id = ids[t]; @@ -128,22 +141,22 @@ static void bert_forward( } // Step 4: 12 layers — shared params (Albert num_hidden_groups=1). - const float * attn_q_w = T.get("kokoro.bert.layer.attn_q.weight"); - const float * attn_q_b = T.get("kokoro.bert.layer.attn_q.bias"); - const float * attn_k_w = T.get("kokoro.bert.layer.attn_k.weight"); - const float * attn_k_b = T.get("kokoro.bert.layer.attn_k.bias"); - const float * attn_v_w = T.get("kokoro.bert.layer.attn_v.weight"); - const float * attn_v_b = T.get("kokoro.bert.layer.attn_v.bias"); - const float * attn_o_w = T.get("kokoro.bert.layer.attn_o.weight"); - const float * attn_o_b = T.get("kokoro.bert.layer.attn_o.bias"); - const float * attn_ln_w = T.get("kokoro.bert.layer.attn_ln.weight"); - const float * attn_ln_b = T.get("kokoro.bert.layer.attn_ln.bias"); - const float * full_ln_w = T.get("kokoro.bert.layer.full_ln.weight"); - const float * full_ln_b = T.get("kokoro.bert.layer.full_ln.bias"); - const float * ffn_w = T.get("kokoro.bert.layer.ffn.weight"); // [2048, 768] - const float * ffn_b = T.get("kokoro.bert.layer.ffn.bias"); - const float * ffn_out_w = T.get("kokoro.bert.layer.ffn_out.weight"); // [768, 2048] - const float * ffn_out_b = T.get("kokoro.bert.layer.ffn_out.bias"); + const float * attn_q_w = T.get(".bert.layer.attn_q.weight"); + const float * attn_q_b = T.get(".bert.layer.attn_q.bias"); + const float * attn_k_w = T.get(".bert.layer.attn_k.weight"); + const float * attn_k_b = T.get(".bert.layer.attn_k.bias"); + const float * attn_v_w = T.get(".bert.layer.attn_v.weight"); + const float * attn_v_b = T.get(".bert.layer.attn_v.bias"); + const float * attn_o_w = T.get(".bert.layer.attn_o.weight"); + const float * attn_o_b = T.get(".bert.layer.attn_o.bias"); + const float * attn_ln_w = T.get(".bert.layer.attn_ln.weight"); + const float * attn_ln_b = T.get(".bert.layer.attn_ln.bias"); + const float * full_ln_w = T.get(".bert.layer.full_ln.weight"); + const float * full_ln_b = T.get(".bert.layer.full_ln.bias"); + const float * ffn_w = T.get(".bert.layer.ffn.weight"); // [2048, 768] + const float * ffn_b = T.get(".bert.layer.ffn.bias"); + const float * ffn_out_w = T.get(".bert.layer.ffn_out.weight"); // [768, 2048] + const float * ffn_out_b = T.get(".bert.layer.ffn_out.bias"); std::vector q(N * d_model), k(N * d_model), v(N * d_model); std::vector ctx(N * d_model); @@ -275,7 +288,7 @@ static void duration_encoder_forward( for (int il = 0; il < n_layer; ++il) { // BiLSTM over x [T, hid+sty] → y [T, 2*half=hid]. - const std::string pfx = "kokoro.predictor.de.lstm" + std::to_string(il) + "."; + const std::string pfx = ".predictor.de.lstm" + std::to_string(il) + "."; lw.W_ih = TL.get(pfx + "weight_ih_l0"); lw.W_hh = TL.get(pfx + "weight_hh_l0"); lw.b_ih = TL.get(pfx + "bias_ih_l0"); @@ -288,7 +301,7 @@ static void duration_encoder_forward( bilstm_forward(x.data(), T_phon, d_hid + sty_dim, half, lw, y.data()); // AdaLayerNorm(style_dim, channels=d_hid). fc(s) → 2*d_hid; chunk → gamma,beta. - const std::string apfx = "kokoro.predictor.de.adaln" + std::to_string(il) + ".fc."; + const std::string apfx = ".predictor.de.adaln" + std::to_string(il) + ".fc."; const float * fc_w = TL.get(apfx + "weight"); const float * fc_b = TL.get(apfx + "bias"); @@ -320,14 +333,14 @@ static void predictor_main_lstm_forward( const float * d /* [T_phon, 640] */, int T_phon, int d_hid, int sty_dim, std::vector & out /* [T_phon, 512] */) { LSTMWeights lw; - lw.W_ih = TL.get("kokoro.predictor.lstm.weight_ih_l0"); - lw.W_hh = TL.get("kokoro.predictor.lstm.weight_hh_l0"); - lw.b_ih = TL.get("kokoro.predictor.lstm.bias_ih_l0"); - lw.b_hh = TL.get("kokoro.predictor.lstm.bias_hh_l0"); - lw.W_ih_r = TL.get("kokoro.predictor.lstm.weight_ih_l0_r"); - lw.W_hh_r = TL.get("kokoro.predictor.lstm.weight_hh_l0_r"); - lw.b_ih_r = TL.get("kokoro.predictor.lstm.bias_ih_l0_r"); - lw.b_hh_r = TL.get("kokoro.predictor.lstm.bias_hh_l0_r"); + lw.W_ih = TL.get(".predictor.lstm.weight_ih_l0"); + lw.W_hh = TL.get(".predictor.lstm.weight_hh_l0"); + lw.b_ih = TL.get(".predictor.lstm.bias_ih_l0"); + lw.b_hh = TL.get(".predictor.lstm.bias_hh_l0"); + lw.W_ih_r = TL.get(".predictor.lstm.weight_ih_l0_r"); + lw.W_hh_r = TL.get(".predictor.lstm.weight_hh_l0_r"); + lw.b_ih_r = TL.get(".predictor.lstm.bias_ih_l0_r"); + lw.b_hh_r = TL.get(".predictor.lstm.bias_hh_l0_r"); out.assign(T_phon * d_hid, 0.0f); bilstm_forward(d, T_phon, d_hid + sty_dim, d_hid / 2, lw, out.data()); } @@ -339,10 +352,11 @@ static void predictor_main_lstm_forward( static void duration_predictor_forward( const TLookup & TL, const float * x_lstm, int T_phon, int d_hid, int max_dur, float speed, + bool torch_round, std::vector & duration /* [T_phon] */, std::vector & pred_dur /* [T_phon] */) { - const float * w = TL.get("kokoro.predictor.duration_proj.weight"); - const float * b = TL.get("kokoro.predictor.duration_proj.bias"); + const float * w = TL.get(".predictor.duration_proj.weight"); + const float * b = TL.get(".predictor.duration_proj.bias"); duration.assign(T_phon, 0.0f); pred_dur.assign(T_phon, 1); std::vector tmp(max_dur); @@ -355,7 +369,10 @@ static void duration_predictor_forward( } const float dur_f = (float)(sum / speed); duration[t] = dur_f; - const int32_t d_round = (int32_t) std::round(dur_f); + // torch.round rounds half to even; Kokoro-82M keeps std::round + // (half away from zero) so its durations are unchanged. + const int32_t d_round = torch_round ? (int32_t) std::nearbyint(dur_f) + : (int32_t) std::round(dur_f); pred_dur[t] = std::max(1, d_round); } } @@ -400,11 +417,10 @@ static void length_regulate( // ================================================================= static void text_encoder_forward( const TLookup & TL, - const std::vector & ids, int n_cnn, int hid, + const std::vector & ids, int n_cnn, int hid, int K, std::vector & out /* [T, hid] */) { const int T = (int) ids.size(); - const int K = 5; - const float * embd = TL.get("kokoro.text_encoder.embd.weight"); // [vocab, hid] + const float * embd = TL.get(".text_encoder.embd.weight"); // [vocab, hid] if (!embd) return; // Step 1: embedding → [T, hid]. @@ -421,7 +437,7 @@ static void text_encoder_forward( std::vector tmp(hid * T); for (int il = 0; il < n_cnn; ++il) { - const std::string cpfx = "kokoro.text_encoder.cnn." + std::to_string(il) + "."; + const std::string cpfx = ".text_encoder.cnn." + std::to_string(il) + "."; const float * cw = TL.get(cpfx + "weight"); const float * cb = TL.get(cpfx + "bias"); conv1d_forward(x_ct.data(), hid, T, cw, cb, hid, K, @@ -432,8 +448,8 @@ static void text_encoder_forward( // LayerNorm: gamma/beta over channel dim. The Python LayerNorm // transposes (1,-1) before F.layer_norm — that swaps channels onto // the last dim. So we normalize across channels for each time step. - const float * gamma = TL.get(std::string("kokoro.text_encoder.ln.") + std::to_string(il) + ".gamma"); - const float * beta = TL.get(std::string("kokoro.text_encoder.ln.") + std::to_string(il) + ".beta"); + const float * gamma = TL.get(std::string(".text_encoder.ln.") + std::to_string(il) + ".gamma"); + const float * beta = TL.get(std::string(".text_encoder.ln.") + std::to_string(il) + ".beta"); for (int t = 0; t < T; ++t) { double sum = 0, sumsq = 0; for (int c = 0; c < hid; ++c) { @@ -462,14 +478,14 @@ static void text_encoder_forward( // BiLSTM. LSTMWeights lw; - lw.W_ih = TL.get("kokoro.text_encoder.lstm.weight_ih_l0"); - lw.W_hh = TL.get("kokoro.text_encoder.lstm.weight_hh_l0"); - lw.b_ih = TL.get("kokoro.text_encoder.lstm.bias_ih_l0"); - lw.b_hh = TL.get("kokoro.text_encoder.lstm.bias_hh_l0"); - lw.W_ih_r = TL.get("kokoro.text_encoder.lstm.weight_ih_l0_r"); - lw.W_hh_r = TL.get("kokoro.text_encoder.lstm.weight_hh_l0_r"); - lw.b_ih_r = TL.get("kokoro.text_encoder.lstm.bias_ih_l0_r"); - lw.b_hh_r = TL.get("kokoro.text_encoder.lstm.bias_hh_l0_r"); + lw.W_ih = TL.get(".text_encoder.lstm.weight_ih_l0"); + lw.W_hh = TL.get(".text_encoder.lstm.weight_hh_l0"); + lw.b_ih = TL.get(".text_encoder.lstm.bias_ih_l0"); + lw.b_hh = TL.get(".text_encoder.lstm.bias_hh_l0"); + lw.W_ih_r = TL.get(".text_encoder.lstm.weight_ih_l0_r"); + lw.W_hh_r = TL.get(".text_encoder.lstm.weight_hh_l0_r"); + lw.b_ih_r = TL.get(".text_encoder.lstm.bias_ih_l0_r"); + lw.b_hh_r = TL.get(".text_encoder.lstm.bias_hh_l0_r"); out.assign(T * hid, 0.0f); bilstm_forward(x_th2.data(), T, hid, hid / 2, lw, out.data()); } @@ -500,7 +516,7 @@ static void text_encoder_forward( static void adainresblk1d_forward( const TLookup & TL, const std::string & pfx, const float * x, int Cin, int T_in, int Sdim, const float * s, - int Cout, bool upsample, bool learned_sc, + int Cout, bool upsample, bool learned_sc, bool instnorm_affine, std::vector & y, int & T_out) { const float * n1_w = TL.get(pfx + ".norm1.fc.weight"); @@ -515,11 +531,16 @@ static void adainresblk1d_forward( const float * c1x1_b = learned_sc ? TL.get_or_null(pfx + ".conv1x1.bias") : nullptr; const float * pool_w = upsample ? TL.get(pfx + ".pool.weight") : nullptr; const float * pool_b = upsample ? TL.get(pfx + ".pool.bias") : nullptr; + // Trained InstanceNorm affine (students); absent/unused for Kokoro-82M. + const float * n1_nw = instnorm_affine ? TL.get(pfx + ".norm1.norm.weight") : nullptr; + const float * n1_nb = instnorm_affine ? TL.get(pfx + ".norm1.norm.bias") : nullptr; + const float * n2_nw = instnorm_affine ? TL.get(pfx + ".norm2.norm.weight") : nullptr; + const float * n2_nb = instnorm_affine ? TL.get(pfx + ".norm2.norm.bias") : nullptr; // === Residual branch === // norm1 (AdaIN1d on Cin channels) std::vector r(x, x + (size_t)Cin * T_in); // copy - adain1d_forward(r.data(), Cin, T_in, s, Sdim, n1_w, n1_b); + adain1d_forward(r.data(), Cin, T_in, s, Sdim, n1_w, n1_b, n1_nw, n1_nb); // leaky relu for (size_t i = 0; i < r.size(); ++i) if (r[i] < 0) r[i] *= 0.2f; @@ -540,7 +561,7 @@ static void adainresblk1d_forward( /*stride*/1, /*pad*/1, /*dil*/1, r3.data(), T_after_pool); // norm2 (AdaIN1d on Cout channels) - adain1d_forward(r3.data(), Cout, T_after_pool, s, Sdim, n2_w, n2_b); + adain1d_forward(r3.data(), Cout, T_after_pool, s, Sdim, n2_w, n2_b, n2_nw, n2_nb); // leaky relu for (size_t i = 0; i < r3.size(); ++i) if (r3[i] < 0) r3[i] *= 0.2f; @@ -600,17 +621,17 @@ static void adainresblk1d_forward( static void f0n_train_forward( const TLookup & TL, const float * en /* [T_frame, 640] */, int T_frame, int d_hid, int sty_dim, - const float * s /* [sty_dim] = 128 */, + const float * s /* [sty_dim] */, bool instnorm_affine, std::vector & F0_pred, std::vector & N_pred) { LSTMWeights lw; - lw.W_ih = TL.get("kokoro.predictor.shared.weight_ih_l0"); - lw.W_hh = TL.get("kokoro.predictor.shared.weight_hh_l0"); - lw.b_ih = TL.get("kokoro.predictor.shared.bias_ih_l0"); - lw.b_hh = TL.get("kokoro.predictor.shared.bias_hh_l0"); - lw.W_ih_r = TL.get("kokoro.predictor.shared.weight_ih_l0_r"); - lw.W_hh_r = TL.get("kokoro.predictor.shared.weight_hh_l0_r"); - lw.b_ih_r = TL.get("kokoro.predictor.shared.bias_ih_l0_r"); - lw.b_hh_r = TL.get("kokoro.predictor.shared.bias_hh_l0_r"); + lw.W_ih = TL.get(".predictor.shared.weight_ih_l0"); + lw.W_hh = TL.get(".predictor.shared.weight_hh_l0"); + lw.b_ih = TL.get(".predictor.shared.bias_ih_l0"); + lw.b_hh = TL.get(".predictor.shared.bias_hh_l0"); + lw.W_ih_r = TL.get(".predictor.shared.weight_ih_l0_r"); + lw.W_hh_r = TL.get(".predictor.shared.weight_hh_l0_r"); + lw.b_ih_r = TL.get(".predictor.shared.bias_ih_l0_r"); + lw.b_hh_r = TL.get(".predictor.shared.bias_hh_l0_r"); std::vector x_shared(T_frame * d_hid); bilstm_forward(en, T_frame, d_hid + sty_dim, d_hid / 2, lw, x_shared.data()); @@ -631,19 +652,19 @@ static void f0n_train_forward( // F0.1: Cin=d_hid, Cout=d_hid/2, upsample=True, learned_sc=True // F0.2: Cin=d_hid/2, Cout=d_hid/2, upsample=False, learned_sc=False std::vector tmp; int T_after = T_frame; - adainresblk1d_forward(TL, "kokoro.predictor.F0.0", F0.data(), d_hid, T_after, sty_dim, s, - d_hid, false, false, tmp, T_after); + adainresblk1d_forward(TL, ".predictor.F0.0", F0.data(), d_hid, T_after, sty_dim, s, + d_hid, false, false, instnorm_affine, tmp, T_after); F0 = std::move(tmp); - adainresblk1d_forward(TL, "kokoro.predictor.F0.1", F0.data(), d_hid, T_after, sty_dim, s, - d_hid / 2, true, true, tmp, T_after); + adainresblk1d_forward(TL, ".predictor.F0.1", F0.data(), d_hid, T_after, sty_dim, s, + d_hid / 2, true, true, instnorm_affine, tmp, T_after); F0 = std::move(tmp); - adainresblk1d_forward(TL, "kokoro.predictor.F0.2", F0.data(), d_hid / 2, T_after, sty_dim, s, - d_hid / 2, false, false, tmp, T_after); + adainresblk1d_forward(TL, ".predictor.F0.2", F0.data(), d_hid / 2, T_after, sty_dim, s, + d_hid / 2, false, false, instnorm_affine, tmp, T_after); F0 = std::move(tmp); // F0_proj: Conv1d(d_hid/2, 1, kernel=1) → [1, T_after]. { - const float * w = TL.get("kokoro.predictor.F0_proj.weight"); - const float * b = TL.get("kokoro.predictor.F0_proj.bias"); + const float * w = TL.get(".predictor.F0_proj.weight"); + const float * b = TL.get(".predictor.F0_proj.bias"); F0_pred.assign(T_after, 0.0f); std::vector proj(1 * T_after); conv1d_forward(F0.data(), d_hid / 2, T_after, w, b, 1, 1, 1, 0, 1, proj.data(), T_after); @@ -652,100 +673,209 @@ static void f0n_train_forward( // N chain (mirror). int T_after_N = T_frame; - adainresblk1d_forward(TL, "kokoro.predictor.N.0", N.data(), d_hid, T_after_N, sty_dim, s, - d_hid, false, false, tmp, T_after_N); + adainresblk1d_forward(TL, ".predictor.N.0", N.data(), d_hid, T_after_N, sty_dim, s, + d_hid, false, false, instnorm_affine, tmp, T_after_N); N = std::move(tmp); - adainresblk1d_forward(TL, "kokoro.predictor.N.1", N.data(), d_hid, T_after_N, sty_dim, s, - d_hid / 2, true, true, tmp, T_after_N); + adainresblk1d_forward(TL, ".predictor.N.1", N.data(), d_hid, T_after_N, sty_dim, s, + d_hid / 2, true, true, instnorm_affine, tmp, T_after_N); N = std::move(tmp); - adainresblk1d_forward(TL, "kokoro.predictor.N.2", N.data(), d_hid / 2, T_after_N, sty_dim, s, - d_hid / 2, false, false, tmp, T_after_N); + adainresblk1d_forward(TL, ".predictor.N.2", N.data(), d_hid / 2, T_after_N, sty_dim, s, + d_hid / 2, false, false, instnorm_affine, tmp, T_after_N); N = std::move(tmp); { - const float * w = TL.get("kokoro.predictor.N_proj.weight"); - const float * b = TL.get("kokoro.predictor.N_proj.bias"); + const float * w = TL.get(".predictor.N_proj.weight"); + const float * b = TL.get(".predictor.N_proj.bias"); std::vector proj(1 * T_after_N); conv1d_forward(N.data(), d_hid / 2, T_after_N, w, b, 1, 1, 1, 0, 1, proj.data(), T_after_N); N_pred = std::move(proj); } } + +// ================================================================= +// asr projection MLP (Paradee TextStudent preset "+mlp"): +// asr_tok = Linear(asr_dim, asr_dim)(GELU(Linear(hid, asr_dim)(t_en))) +// nn.GELU() is the exact (erf) GELU, not ALBERT's tanh approximation. +// ================================================================= +static bool asr_proj_mlp_forward( + const TLookup & TL, const float * t_en, int T_phon, int hid, + std::vector & out, int & asr_dim, std::string & err) { + ggml_tensor * w0t = TL.tensor(".asr_proj.0.weight"); // [asr_dim, hid] + ggml_tensor * w1t = TL.tensor(".asr_proj.1.weight"); // [asr_dim, asr_dim] + const float * b0 = TL.get(".asr_proj.0.bias"); + const float * b1 = TL.get(".asr_proj.1.bias"); + if (!w0t || !w1t || !b0 || !b1) { err = "missing asr_proj tensors"; return false; } + if ((int) w0t->ne[0] != hid || w1t->ne[0] != w0t->ne[1] || w1t->ne[1] != w0t->ne[1]) { + err = "asr_proj tensor shapes do not match the predictor width"; + return false; + } + asr_dim = (int) w0t->ne[1]; + const float * w0 = (const float *) w0t->data; + const float * w1 = (const float *) w1t->data; + out.assign((size_t) T_phon * asr_dim, 0.0f); + std::vector h((size_t) asr_dim); + const float inv_sqrt2 = 0.70710678118654752440f; + for (int t = 0; t < T_phon; ++t) { + linear_forward(t_en + (size_t) t * hid, hid, w0, b0, asr_dim, h.data()); + for (int j = 0; j < asr_dim; ++j) { + const float v = h[(size_t) j]; + h[(size_t) j] = 0.5f * v * (1.0f + std::erf(v * inv_sqrt2)); + } + linear_forward(h.data(), asr_dim, w1, b1, asr_dim, out.data() + (size_t) t * asr_dim); + } + return true; +} + } // namespace // ================================================================= -// Public entry: full predictor forward. +// Generic entry: full predictor forward over a loaded F32 context. // ================================================================= -bool kokoro_predictor_forward( - const kokoro_model * model, +bool predictor_forward( + ggml_context * ctx, + const PredictorConfig & cfg, const std::vector & phoneme_ids, - const float * ref_s, float speed, + float speed, PredictorOut & out, std::string & err_out) { - if (!model) { err_out = "null model"; return false; } - const kokoro_hparams * hp = kokoro_get_hparams(model); - if (!hp) { err_out = "null hparams"; return false; } - - TLookup TL { kokoro_model_ggml_ctx(model) }; + if (!ctx) { err_out = "null model context"; return false; } + if (!cfg.style) { err_out = "null predictor style"; return false; } + if (phoneme_ids.empty()) { err_out = "empty phoneme ids"; return false; } + + TLookup TL { ctx, cfg.ns }; + const auto cancelled = [&]() { + if (!cfg.cancel || !cfg.cancel(cfg.cancel_user)) return false; + out.cancelled = true; + err_out = "cancelled"; + return true; + }; + + // Widths from the tensor shapes (ggml ne[] is the reversed PyTorch shape): + // bert_encoder.weight [hid, d_model] -> ne[1] = hid + // predictor.de.adaln0.fc.weight [2*hid, sty] -> ne[0] = sty + ggml_tensor * be_t = TL.tensor(".bert_encoder.weight"); + ggml_tensor * adaln_t = TL.tensor(".predictor.de.adaln0.fc.weight"); + ggml_tensor * dur_t = TL.tensor(".predictor.duration_proj.weight"); + if (!be_t || !adaln_t || !dur_t) { err_out = "missing predictor tensors"; return false; } + const int hid = (int) be_t->ne[1]; + const int sty = (int) adaln_t->ne[0]; + if ((int) be_t->ne[0] != cfg.text_d_model || (int) adaln_t->ne[1] != 2 * hid || + (int) dur_t->ne[0] != hid || (int) dur_t->ne[1] != cfg.max_dur) { + err_out = "predictor tensor shapes do not match the hparams"; + return false; + } const int T_phon = (int) phoneme_ids.size(); out.T_phon = T_phon; - const int d_model = hp->text_d_model; - const int d_ff = hp->text_d_ff; - const int n_layer = hp->text_n_layer; - const int n_head = hp->text_n_head; - const int hid = 512; // hidden_dim from config.json - const int sty = 128; // per-half style_dim - const int max_dur = 50; - const int pred_nl = 3; + out.hid = hid; + out.sty = sty; + out.cancelled = false; + const int d_model = cfg.text_d_model; + const int d_ff = cfg.text_d_ff; + const int n_layer = cfg.text_n_layer; + const int n_head = cfg.text_n_head; + const int max_dur = cfg.max_dur; + const int pred_nl = cfg.n_layer; + const float * style = cfg.style; // 1. BERT. std::vector bert_out; bert_forward(TL, phoneme_ids, d_model, d_ff, n_layer, n_head, bert_out, err_out); if (!err_out.empty()) return false; + if (cancelled()) return false; - // 2. bert_encoder Linear 768→512 → d_en [T, 512] row-major. - const float * be_w = TL.get("kokoro.bert_encoder.weight"); - const float * be_b = TL.get("kokoro.bert_encoder.bias"); + // 2. bert_encoder Linear d_model→hid → d_en [T, hid] row-major. + const float * be_w = TL.get(".bert_encoder.weight"); + const float * be_b = TL.get(".bert_encoder.bias"); if (!be_w || !be_b) { err_out = "missing bert_encoder weights"; return false; } std::vector d_en((size_t)T_phon * hid); for (int t = 0; t < T_phon; ++t) { linear_forward(bert_out.data() + (size_t)t * d_model, d_model, be_w, be_b, hid, d_en.data() + (size_t)t * hid); } + if (cfg.keep_bert) out.bert = std::move(bert_out); // 3. DurationEncoder. - duration_encoder_forward(TL, d_en.data(), T_phon, hid, sty, pred_nl, - ref_s + hid /* predictor-half = ref_s[128:] */, out.d); + duration_encoder_forward(TL, d_en.data(), T_phon, hid, sty, pred_nl, style, out.d); // 4. predictor.lstm. predictor_main_lstm_forward(TL, out.d.data(), T_phon, hid, sty, out.x_lstm); // 5. duration_proj → sigmoid → sum → /speed → pred_dur. duration_predictor_forward(TL, out.x_lstm.data(), T_phon, hid, max_dur, speed, - out.duration, out.pred_dur); + cfg.torch_round, out.duration, out.pred_dur); + if (cancelled()) return false; - // 6. Length regulate `d` → en [T_frame, 640]. + // 6. Length regulate `d` → en [T_frame, hid+sty]. length_regulate(out.d.data(), T_phon, hid + sty, out.pred_dur, out.en, out.T_frame); // 7. text_encoder forward (separate from BERT). - text_encoder_forward(TL, phoneme_ids, pred_nl, hid, out.t_en); + text_encoder_forward(TL, phoneme_ids, pred_nl, hid, cfg.text_kernel, out.t_en); - // 8. Length regulate t_en [T_phon, 512] → asr [T_frame, 512]. + // 8. Length regulate the asr features [T_phon, asr_dim] → asr [T_frame, asr_dim]. { + const float * asr_src = out.t_en.data(); + std::vector asr_tok; + int asr_dim = hid; + if (cfg.asr_proj_mlp) { + if (!asr_proj_mlp_forward(TL, out.t_en.data(), T_phon, hid, asr_tok, asr_dim, err_out)) { + return false; + } + asr_src = asr_tok.data(); + } + out.asr_dim = asr_dim; int T_frame_check = 0; - length_regulate(out.t_en.data(), T_phon, hid, out.pred_dur, out.asr, T_frame_check); + length_regulate(asr_src, T_phon, asr_dim, out.pred_dur, out.asr, T_frame_check); if (T_frame_check != out.T_frame) { err_out = "internal: T_frame mismatch between en and asr"; return false; } } + if (cancelled()) return false; // 9. F0/N predict. // The shared LSTM expects input [T_frame, hid+sty]. en is already that shape // (length-regulated d, which is hid+sty wide). f0n_train_forward(TL, out.en.data(), out.T_frame, hid, sty, - ref_s + hid /* predictor-half */, out.F0_pred, out.N_pred); + style, cfg.instnorm_affine, out.F0_pred, out.N_pred); + if (cancelled()) return false; return true; } +// ================================================================= +// Kokoro-82M entry. +// ================================================================= +bool kokoro_predictor_forward( + const kokoro_model * model, + const std::vector & phoneme_ids, + const float * ref_s, float speed, + PredictorOut & out, std::string & err_out) { + if (!model) { err_out = "null model"; return false; } + const kokoro_hparams * hp = kokoro_get_hparams(model); + if (!hp) { err_out = "null hparams"; return false; } + if (!ref_s) { err_out = "null ref_s"; return false; } + + ggml_context * ctx = kokoro_model_ggml_ctx(model); + TLookup TL { ctx, "kokoro" }; + ggml_tensor * be_t = TL.tensor(".bert_encoder.weight"); + if (!be_t) { err_out = "missing bert_encoder weights"; return false; } + + PredictorConfig cfg; + cfg.ns = "kokoro"; + cfg.text_n_layer = hp->text_n_layer; + cfg.text_n_head = hp->text_n_head; + cfg.text_d_model = hp->text_d_model; + cfg.text_d_ff = hp->text_d_ff; + cfg.n_layer = 3; + cfg.max_dur = 50; + cfg.text_kernel = 5; + // KNOWN ISSUE, kept bit-for-bit here: the predictor style is read at + // ref_s + hid (hid = 512 for Kokoro-82M), not at ref_s + 128 where + // kokoro/model.py takes ref_s[:, 128:]. That is two rows further into the + // voice pack, and past its end for the longest inputs. Fixing it changes + // Kokoro's audio, so it is a separate change with its own parity run. + cfg.style = ref_s + (int) be_t->ne[1]; + return predictor_forward(ctx, cfg, phoneme_ids, speed, out, err_out); +} + } // namespace eliza_kokoro diff --git a/tools/kokoro/src/kokoro.cpp b/tools/kokoro/src/kokoro.cpp index c8213a9fa..f61f65008 100644 --- a/tools/kokoro/src/kokoro.cpp +++ b/tools/kokoro/src/kokoro.cpp @@ -660,16 +660,17 @@ static kokoro_status kokoro_synthesize_from_input_ids( } } const int T = pred.T_frame; - if (T <= 0 || (int) pred.asr.size() != T * 512) { + const int A = pred.asr_dim; // 512 for Kokoro-82M (the text encoder width) + if (T <= 0 || A <= 0 || pred.asr.size() != (size_t) T * (size_t) A) { err_out = "predictor produced empty/invalid asr (T=" + std::to_string(T) + ")"; return KOKORO_E_RUNTIME; } - // Transpose asr [T, 512] (T-major) → [512, T] (channel-major). - std::vector asr_ct((size_t) 512 * (size_t) T); + // Transpose asr [T, A] (T-major) → [A, T] (channel-major). + std::vector asr_ct((size_t) A * (size_t) T); for (int t = 0; t < T; ++t) { - const float * row = pred.asr.data() + (size_t) t * 512; - for (int c = 0; c < 512; ++c) { + const float * row = pred.asr.data() + (size_t) t * A; + for (int c = 0; c < A; ++c) { asr_ct[(size_t) c * (size_t) T + t] = row[c]; } } diff --git a/tools/kokoro/tests/CMakeLists.txt b/tools/kokoro/tests/CMakeLists.txt index 43b22504d..a3bc75b1a 100644 --- a/tools/kokoro/tests/CMakeLists.txt +++ b/tools/kokoro/tests/CMakeLists.txt @@ -38,3 +38,9 @@ add_test(NAME test-kokoro-layers-portable COMMAND test-kokoro-layers-portable) add_executable(test-kokoro-backend test_kokoro_backend.cpp) target_link_libraries(test-kokoro-backend PRIVATE kokoro_lib) add_test(NAME test-kokoro-backend COMMAND test-kokoro-backend) + +# Opt-in layer strategies (KokoroLayerOptions) against the default kernels. +# Kokoro-82M never enables them; Paradee-8M does. +add_executable(test-kokoro-layer-options test_kokoro_layer_options.cpp) +target_link_libraries(test-kokoro-layer-options PRIVATE kokoro_lib) +add_test(NAME test-kokoro-layer-options COMMAND test-kokoro-layer-options) diff --git a/tools/kokoro/tests/test_kokoro_layer_options.cpp b/tools/kokoro/tests/test_kokoro_layer_options.cpp new file mode 100644 index 000000000..7fdd8e520 --- /dev/null +++ b/tools/kokoro/tests/test_kokoro_layer_options.cpp @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: MIT +// +// test-kokoro-layer-options — the opt-in layer strategies in kokoro-layers.h +// (KokoroLayerOptions: tap_gemm_conv, fast_snake) against the default +// kernels. Kokoro-82M never enables them; Paradee-8M does. Checks that: +// - Conv1d (stride 1, dilated, strided) and ConvTranspose1d give the same +// result as the default path within float rounding, +// - the polynomial sin^2 in fast Snake1D is within 5e-7 of libm, +// - the options are scoped (restored when the scope ends). + +#include "kokoro-layers.h" + +#include +#include +#include +#include + +using namespace eliza_kokoro; + +static int failures = 0; +#define CHECK(cond, ...) do { if (!(cond)) { std::fprintf(stderr, "FAIL %s:%d: ", __FILE__, __LINE__); \ + std::fprintf(stderr, __VA_ARGS__); std::fprintf(stderr, "\n"); ++failures; } } while (0) + +static std::vector randn(size_t n, std::mt19937 & g, float scale) { + std::normal_distribution d(0.0f, scale); + std::vector v(n); + for (auto & x : v) x = d(g); + return v; +} + +static double rel_err(const std::vector & a, const std::vector & b) { + double num = 0, den = 0; + for (size_t i = 0; i < a.size(); ++i) { + num = std::max(num, (double) std::fabs(a[i] - b[i])); + den = std::max(den, (double) std::fabs(a[i])); + } + return num / std::max(den, 1e-30); +} + +static detail::KokoroLayerOptions opts(bool on) { + detail::KokoroLayerOptions o; + o.tap_gemm_conv = on; + o.fast_snake = on; + return o; +} + +static void check_conv(std::mt19937 & g, int Cin, int Cout, int K, int T, int stride, int pad, int dil) { + const int T_out = (T + 2 * pad - dil * (K - 1) - 1) / stride + 1; + auto x = randn((size_t) Cin * T, g, 1.0f), W = randn((size_t) Cout * Cin * K, g, 0.1f), b = randn(Cout, g, 0.5f); + std::vector y0((size_t) Cout * T_out), y1((size_t) Cout * T_out); + conv1d_forward(x.data(), Cin, T, W.data(), b.data(), Cout, K, stride, pad, dil, y0.data(), T_out); + { + detail::KokoroLayerOptionsScope s(opts(true)); + conv1d_forward(x.data(), Cin, T, W.data(), b.data(), Cout, K, stride, pad, dil, y1.data(), T_out); + } + const double e = rel_err(y0, y1); + std::printf("conv1d Cin=%d Cout=%d K=%d T=%d stride=%d dil=%d: rel err %.3g\n", Cin, Cout, K, T, stride, dil, e); + CHECK(e < 1e-5, "conv1d mismatch %.3g", e); +} + +static void check_convtranspose(std::mt19937 & g, int Cin, int Cout, int K, int T, int stride) { + const int pad = (K - stride) / 2; + const int T_out = convtranspose1d_out_len(T, K, stride, pad, 0); + auto x = randn((size_t) Cin * T, g, 1.0f), W = randn((size_t) Cin * Cout * K, g, 0.1f), b = randn(Cout, g, 0.5f); + std::vector y0((size_t) Cout * T_out), y1((size_t) Cout * T_out); + convtranspose1d_forward(x.data(), Cin, T, W.data(), b.data(), Cout, K, stride, pad, 0, y0.data(), T_out); + { + detail::KokoroLayerOptionsScope s(opts(true)); + convtranspose1d_forward(x.data(), Cin, T, W.data(), b.data(), Cout, K, stride, pad, 0, y1.data(), T_out); + } + const double e = rel_err(y0, y1); + std::printf("convtranspose1d Cin=%d Cout=%d K=%d T=%d stride=%d: rel err %.3g\n", Cin, Cout, K, T, stride, e); + CHECK(e < 1e-5, "convtranspose1d mismatch %.3g", e); +} + +int main() { + std::mt19937 g(1234); + check_conv(g, 32, 32, 11, 1001, 1, 5, 1); + check_conv(g, 32, 32, 7, 997, 1, 15, 5); // dilated (get_padding(7, 5) = 15) + check_conv(g, 514, 256, 3, 47, 1, 1, 1); + check_conv(g, 30, 7, 3, 33, 1, 1, 1); // ragged row / time tiles + check_conv(g, 22, 64, 12, 601, 6, 3, 1); // strided (noise_convs[0]) + check_convtranspose(g, 128, 64, 20, 40, 10); + check_convtranspose(g, 64, 32, 12, 401, 6); + + // fast sin^2 + double worst = 0; + for (int i = -200000; i <= 200000; ++i) { + const float y = (float) i * 1e-3f; + const double ref = std::sin((double) y) * std::sin((double) y); + worst = std::max(worst, std::fabs((double) detail::fast_sin2(y) - ref)); + } + std::printf("fast_sin2 on [-200, 200]: max |err| %.3g\n", worst); + CHECK(worst < 5e-7, "fast_sin2 error %.3g", worst); + + // Snake with the option on matches libm within that bound. + { + auto x = randn(4 * 1000, g, 3.0f), a = randn(4, g, 1.0f); + std::vector x1 = x; + snake1d_forward(x.data(), 4, 1000, a.data()); + { + detail::KokoroLayerOptionsScope s(opts(true)); + snake1d_forward(x1.data(), 4, 1000, a.data()); + } + double e = 0; + for (int c = 0; c < 4; ++c) + for (int t = 0; t < 1000; ++t) + e = std::max(e, std::fabs((double) x[c * 1000 + t] - x1[c * 1000 + t]) * std::fabs(a[c])); + CHECK(e < 1e-6, "snake mismatch %.3g", e); + } + + // Scoped: the options are off again outside the scope. + { + detail::KokoroLayerOptionsScope s(opts(true)); + CHECK(detail::kokoro_layer_options().fast_snake, "scope did not set the option"); + } + CHECK(!detail::kokoro_layer_options().fast_snake && !detail::kokoro_layer_options().tap_gemm_conv, + "options leaked out of the scope"); + + if (failures) { std::fprintf(stderr, "%d failure(s)\n", failures); return 1; } + std::printf("test-kokoro-layer-options: OK\n"); + return 0; +} From 5c4bd3254913d4153ec48e94aecdecad0e18d2be Mon Sep 17 00:00:00 2001 From: Shaw Date: Tue, 13 Oct 2026 23:00:59 -0400 Subject: [PATCH 3/3] tools/paradee: native Paradee-8M graph, phase-locking filter and parity gate paradee_lib runs Paradee-8M v1.0 on the generalised tools/kokoro layers: ALBERT 6x256 -> duration encoder (style 32) -> integer durations -> F0/N; TextEncoder -> asr_proj (192 -> 512 -> 512, exact GELU); AdaIN decoder (256, style 16) -> iSTFTNet generator (128 -> 64 -> 32) with the deterministic source; then the phase lock. paradee-phase-lock.cpp ports training/scripts/phase_lock.py lock(): STFT 1024/256 with reflect padding, bins 2-8 kHz, voiced frames (F0 > 60 Hz), 33-frame Hann smoothing of S*conj(E)/|E|, phase = angle(E) + angle(smoothed), magnitude kept, out = s + iSTFT(change). Radix-2 FFT in double; no ONNX. API (include/paradee.h): paradee_load_model, paradee_sample_rate, paradee_phonemes_to_ids (adds the pads, rejects >510 symbols and symbols outside the vocabulary), paradee_phonemes_to_symbol_ids (no pads or limit, for chunkers), paradee_synthesize_ids (cancel polled between stages and in the generator and lock loops), paradee_synthesize (struct form), and the staged/decode/lock entry points the parity gate uses. paradee-tts writes a WAV from misaki phonemes; paradee-stage-dump writes every stage. LLAMA_BUILD_PARADEE (default ON, needs LLAMA_BUILD_KOKORO) and the LLAMA_BUILD_MISAKI_G2P hook are added to the root and tools CMake, both ahead of tools/omnivoice so the fused library can fold them in. Parity gate: tools/dump_paradee_reference.py runs the pinned PyTorch Paradee with a deterministic source on the misaki 0.9.4 phonemes of the model card's samples/sentences.txt (committed as tests/fixtures/sentences.misaki.json) and dumps every stage. It also writes the self-contained lock fixture, and reports the log-mel distance to the published seeded samples and the whisper.cpp WER (report only). Tests: - test-paradee-phase-lock (always runs): the native lock is within 3e-8 of phase_lock.lock() in float64 (gate 1e-4). - test-paradee-api (PARADEE_GGUF): ids, vocabulary and length errors, input validation, determinism, speed, cancellation. - test-paradee-parity (PARADEE_GGUF + PARADEE_REF_DIR), all 5 sentences: - integer durations are identical; - bert/d/dur/F0/N/asr are within 7e-6 x RMS; - source and decoder output, each fed the reference's inputs, are within 3e-5 x RMS (gate 1e-3); - the lock alone is within 4e-9 of float64 phase_lock (gate 1e-4); - end-to-end waveform SNR is 58-76 dB (gate 35 dB). Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 28 + tools/CMakeLists.txt | 19 + tools/paradee/CMakeLists.txt | 47 ++ tools/paradee/README.md | 125 +++ tools/paradee/include/paradee.h | 198 +++++ tools/paradee/src/paradee-phase-lock.cpp | 224 ++++++ tools/paradee/src/paradee.cpp | 635 +++++++++++++++ tools/paradee/tests/CMakeLists.txt | 45 ++ .../tests/fixtures/phase_lock_synthetic.f32 | Bin 0 -> 76800 bytes .../tests/fixtures/sentences.misaki.json | 758 ++++++++++++++++++ tools/paradee/tests/test_paradee_api.cpp | 153 ++++ tools/paradee/tests/test_paradee_parity.cpp | 204 +++++ .../paradee/tests/test_paradee_phase_lock.cpp | 134 ++++ tools/paradee/tools/dump_paradee_reference.py | 561 +++++++++++++ tools/paradee/tools/paradee-stage-dump.cpp | 91 +++ tools/paradee/tools/paradee-tts.cpp | 113 +++ 16 files changed, 3335 insertions(+) create mode 100644 tools/paradee/CMakeLists.txt create mode 100644 tools/paradee/README.md create mode 100644 tools/paradee/include/paradee.h create mode 100644 tools/paradee/src/paradee-phase-lock.cpp create mode 100644 tools/paradee/src/paradee.cpp create mode 100644 tools/paradee/tests/CMakeLists.txt create mode 100644 tools/paradee/tests/fixtures/phase_lock_synthetic.f32 create mode 100644 tools/paradee/tests/fixtures/sentences.misaki.json create mode 100644 tools/paradee/tests/test_paradee_api.cpp create mode 100644 tools/paradee/tests/test_paradee_parity.cpp create mode 100644 tools/paradee/tests/test_paradee_phase_lock.cpp create mode 100644 tools/paradee/tools/dump_paradee_reference.py create mode 100644 tools/paradee/tools/paradee-stage-dump.cpp create mode 100644 tools/paradee/tools/paradee-tts.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 60a4503d4..c6f72f890 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -131,6 +131,16 @@ option(OMNIVOICE_SHARED "Build libomnivoice for ABI consumers" OFF) # Kokoro is a first-class voice surface in the eliza-1 stack. option(LLAMA_BUILD_KOKORO "Build the Kokoro-82M TTS subtree (tools/kokoro/)" ON) +# Paradee-8M v1.0 (single-voice distillation of Kokoro-82M, Apache-2.0). +# Produces paradee_lib + paradee-tts. Runs on kokoro_lib's layers, so it is +# built only together with LLAMA_BUILD_KOKORO. Default ON: Paradee is the +# shipped mobile voice. +option(LLAMA_BUILD_PARADEE "Build the Paradee-8M TTS subtree (tools/paradee/)" ON) + +# Native misaki grapheme-to-phoneme (Paradee/Kokoro phoneme spelling). The +# subtree lives in tools/misaki-g2p/ and is added when present. +option(LLAMA_BUILD_MISAKI_G2P "Build the misaki G2P subtree (tools/misaki-g2p/)" ON) + # W3-3 deprecation: ELIZA_FUSE_OMNIVOICE=ON is the pre-W3-3 flag for the # fork-root `omnivoice/` graft. The graft is gone (see the deprecation # block below); turn the flag into a redirect now so the option ON state @@ -281,6 +291,24 @@ if (LLAMA_BUILD_KOKORO AND NOT (LLAMA_BUILD_COMMON AND LLAMA_BUILD_TOOLS)) add_subdirectory(tools/kokoro) endif() +# misaki G2P and Paradee — embed-as-library hooks (mirror kokoro above). Both +# precede tools/omnivoice so the fused `elizainference` target can fold them +# in with `if(TARGET ...)`. +if (LLAMA_BUILD_MISAKI_G2P AND NOT (LLAMA_BUILD_COMMON AND LLAMA_BUILD_TOOLS)) + if (EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/tools/misaki-g2p/CMakeLists.txt") + add_subdirectory(tools/misaki-g2p) + else() + message(STATUS "LLAMA_BUILD_MISAKI_G2P: tools/misaki-g2p/ is not in this checkout; skipped") + endif() +endif() +if (LLAMA_BUILD_PARADEE AND NOT (LLAMA_BUILD_COMMON AND LLAMA_BUILD_TOOLS)) + if (NOT LLAMA_BUILD_KOKORO) + message(FATAL_ERROR "LLAMA_BUILD_PARADEE=ON needs LLAMA_BUILD_KOKORO=ON (Paradee runs on " + "the tools/kokoro layers); pass -DLLAMA_BUILD_PARADEE=OFF to build without it.") + endif() + add_subdirectory(tools/paradee) +endif() + if (LLAMA_BUILD_OMNIVOICE) if (NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/tools/omnivoice/CMakeLists.txt") message(FATAL_ERROR diff --git a/tools/CMakeLists.txt b/tools/CMakeLists.txt index 3a194f1c3..3b3f72e6a 100644 --- a/tools/CMakeLists.txt +++ b/tools/CMakeLists.txt @@ -41,6 +41,25 @@ else() # this ordering for the embed-as-library (LLAMA_BUILD_TOOLS=OFF) case. add_subdirectory(kokoro) endif() + if (LLAMA_BUILD_MISAKI_G2P) + # Native misaki G2P (the phoneme spelling Paradee and Kokoro were + # trained on). Added when the subtree is present. + if (EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/misaki-g2p/CMakeLists.txt") + add_subdirectory(misaki-g2p) + else() + message(STATUS "LLAMA_BUILD_MISAKI_G2P: tools/misaki-g2p/ is not in this checkout; skipped") + endif() + endif() + if (LLAMA_BUILD_PARADEE) + # Paradee-8M (width-scaled Kokoro student, single voice). Needs + # kokoro_lib; like kokoro it precedes omnivoice so the fused + # `elizainference` target can fold paradee_lib in. + if (NOT LLAMA_BUILD_KOKORO) + message(FATAL_ERROR "LLAMA_BUILD_PARADEE=ON needs LLAMA_BUILD_KOKORO=ON (Paradee runs on " + "the tools/kokoro layers); pass -DLLAMA_BUILD_PARADEE=OFF to build without it.") + endif() + add_subdirectory(paradee) + endif() if (LLAMA_BUILD_OMNIVOICE) # The merged omnivoice subtree lives under tools/omnivoice/. The # top-level CMakeLists.txt has a parallel add_subdirectory hook for diff --git a/tools/paradee/CMakeLists.txt b/tools/paradee/CMakeLists.txt new file mode 100644 index 000000000..0cf849b45 --- /dev/null +++ b/tools/paradee/CMakeLists.txt @@ -0,0 +1,47 @@ +# tools/paradee/CMakeLists.txt +# +# Paradee-8M v1.0 TTS (sahilmahendrakar/Paradee-8M-v1.0, Apache-2.0): a +# width-scaled, single-voice distillation of Kokoro-82M. Built when +# LLAMA_BUILD_PARADEE=ON (see the top-level CMakeLists.txt). +# +# Produces: +# paradee_lib static library (loader, synthesis, phase-locking filter). +# Reuses kokoro_lib's StyleTTS-2 / iSTFTNet layers. +# paradee-tts CLI: misaki phonemes -> 24 kHz WAV (host only). +# paradee-stage-dump writes every stage for the PyTorch parity gate. + +if (NOT TARGET kokoro_lib) + message(FATAL_ERROR "LLAMA_BUILD_PARADEE=ON needs kokoro_lib (LLAMA_BUILD_KOKORO=ON): " + "Paradee runs on the tools/kokoro layers.") +endif() + +add_library(paradee_lib STATIC + src/paradee.cpp + src/paradee-phase-lock.cpp + include/paradee.h) +set_target_properties(paradee_lib PROPERTIES POSITION_INDEPENDENT_CODE ON) +target_include_directories(paradee_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include) +target_link_libraries(paradee_lib PUBLIC kokoro_lib ggml) +if (TARGET ggml-base) + target_link_libraries(paradee_lib PUBLIC ggml-base) +endif() +target_compile_features(paradee_lib PUBLIC cxx_std_17) +# Same layer configuration as kokoro_lib, so the inline kokoro-layers.h +# definitions agree across the two archives. +target_compile_definitions(paradee_lib PRIVATE KOKORO_USE_GGML_BACKEND) + +if (NOT CMAKE_SYSTEM_NAME STREQUAL "iOS") + include(GNUInstallDirs) + add_executable(paradee-tts tools/paradee-tts.cpp) + target_link_libraries(paradee-tts PRIVATE paradee_lib) + install(TARGETS paradee-tts + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} + BUNDLE DESTINATION ${CMAKE_INSTALL_BINDIR}) + + add_executable(paradee-stage-dump tools/paradee-stage-dump.cpp) + target_link_libraries(paradee-stage-dump PRIVATE paradee_lib) +endif() + +if (LLAMA_BUILD_TESTS) + add_subdirectory(tests) +endif() diff --git a/tools/paradee/README.md b/tools/paradee/README.md new file mode 100644 index 000000000..67f3b6ac6 --- /dev/null +++ b/tools/paradee/README.md @@ -0,0 +1,125 @@ +# tools/paradee: Paradee-8M v1.0 TTS + +Paradee-8M v1.0 ([sahilmahendrakar/Paradee-8M-v1.0](https://huggingface.co/sahilmahendrakar/Paradee-8M-v1.0), Apache-2.0) +is Kokoro-82M's StyleTTS-2 + iSTFTNet code at smaller widths. It is distilled to one voice (`af_heart`), has +8.07M parameters and outputs 24 kHz audio. Its input is misaki phoneme ids with a `0` pad at each end, at most +512 ids, plus a speed value. + +Upstream ships the model as an ONNX graph and as PyTorch weights. This directory runs the PyTorch weights +natively, with no ONNX: + +- `convert_paradee_pt_to_gguf.py` writes a GGUF (`general.architecture = paradee`) from the pinned + `pytorch/text_side.pt` and `pytorch/decoder.pt`. +- `paradee_lib` runs it on the `tools/kokoro` layers. Those layers now read their widths from the tensor + shapes, and Kokoro-82M's output is unchanged bit for bit. +- `src/paradee-phase-lock.cpp` is a native implementation of the model's phase-locking filter. + +## Pipeline + +| Stage | Kokoro-82M | Paradee-8M | +|---|---|---| +| ALBERT | 768 wide, 12 heads, 12 layers | 256 wide, 4 heads, 6 layers (one shared group) | +| predictor hidden / style | 512 / 128 (a voice row) | 192 / 32 (learned constant `paradee.style.text`) | +| text encoder → decoder `asr` | 512, used directly | 192 → `asr_proj` (Linear 192→512, exact GELU, Linear 512→512) | +| decoder AdaIN width / style | 1024 / 128 | 256 / 16 (learned constant `paradee.style.decoder`) | +| generator channels | 512 → 256 → 128 | 128 → 64 → 32 | +| AdaIN1d InstanceNorm affine | identity (untrained) | trained, applied | +| durations | `std::round` | `torch.round` (half to even) | +| after the generator | none | phase lock: STFT 1024/256, 2–8 kHz, voiced frames (F0 > 60 Hz), 33-frame smoothing | + +The harmonic source is deterministic, as in `tools/kokoro`: it has no random initial phase and no additive +noise. Paradee runs it in double precision. Its STFT sets values below the DFT rounding floor to exactly +zero, so the empty bins of an unvoiced, noise-free source have a defined phase. + +## API (`include/paradee.h`) + +```cpp +namespace eliza_paradee { + paradee_model_ptr paradee_load_model(const std::string & gguf, std::string & err); + int paradee_sample_rate(const paradee_model *); + paradee_status paradee_phonemes_to_ids(const paradee_model *, const std::string & misaki_utf8, + std::vector & ids, std::string & err); + paradee_status paradee_synthesize_ids(const paradee_model *, const int32_t * ids, size_t n, float speed, + bool (*cancel)(void *), void * user, + std::vector & pcm, std::string & err); +} +``` + +- `paradee_phonemes_to_ids` adds the pads. It rejects more than 510 symbols (`PARADEE_E_TOO_LONG`), any + symbol outside the vocabulary (`PARADEE_E_UNKNOWN_SYMBOL`, with the code point named), and invalid UTF-8. + It never drops a symbol without saying so. +- `paradee_synthesize_ids` returns `PARADEE_CANCELLED`, with `pcm` empty, as soon as `cancel` returns true. + It checks `cancel` between stages and inside the generator and phase-lock loops. Calls on one model run + one at a time. +- `paradee_synthesize` takes the same arguments in two structs (`paradee_synth_options` and + `paradee_audio`). +- Diagnostics: + - `paradee_synthesize_ids_staged` and `paradee_decode_staged` return every stage. + - `paradee_phase_lock` runs the filter alone. + +## Build and use + +`LLAMA_BUILD_PARADEE` is ON by default. It needs `LLAMA_BUILD_KOKORO`. It builds `paradee_lib`, `paradee-tts` +and `paradee-stage-dump`. + +```sh +python tools/paradee/convert_paradee_pt_to_gguf.py --model-dir \ + --output paradee-8m-v1.0-f32.gguf --check-output +build/bin/paradee-tts --model paradee-8m-v1.0-f32.gguf --phonemes "həlˈO wˈɜɹld." --output hello.wav +``` + +The input is misaki's phoneme spelling, not text and not eSpeak IPA. `tools/misaki-g2p` provides the text +front end. + +## Pins (`pins.json`) + +- **Model:** HF revision `c7f83f86c04e84d0ae94cbe1aad7618e0544f80a`. The `v1.0` tag resolves to commit + `8f34b01e`, which differs only in `README.md`. +- **Files:** size and sha256 of the `.pt` files, `config.json`, `tokenizer.json`, `samples/sentences.txt` and + `samples/*_paradee.wav`. +- **Source:** github.com/sahilmahendrakar/paradee at `9c8b4d75`. The sha256 of each training script the + reference imports is pinned. +- **Reference environment:** torch 2.14.0, kokoro 0.9.4 and misaki 0.9.4, from `training/uv.lock`. +- **GGUF:** the expected sha256 of the F32 converter output. The converter is byte-reproducible. + `--check-output` enforces this sha256. +- **No F16 pin:** `--outtype f16` halves the file (16 MB) but fails the parity gate (end-to-end SNR + 16–23 dB), so it is not pinned or shipped. + +## Parity gate + +`tools/dump_paradee_reference.py` (run it in the reference environment) has four subcommands: + +| Subcommand | What it does | +|---|---| +| `phonemes` | Writes `tests/fixtures/sentences.misaki.json`: misaki 0.9.4 phonemes of `samples/sentences.txt`, from `en.G2P(trf=False, british=False, fallback=EspeakFallback)`. The fixture is committed. | +| `dump` | Runs the pinned PyTorch model on those phonemes and writes every stage: bert, d, dur, integer durations, F0, N, asr, decoder output, source, final waveform, and the float32 lock. | +| `lock-fixture` | Writes `tests/fixtures/phase_lock_synthetic.f32` for the self-contained lock test. | +| `report` | Log-mel distance to the published seeded samples, and WER with whisper.cpp. Report only. | + +The reference is deterministic and makes three documented changes, which the native graph also makes: + +- `SineGen` has `rand_ini = 0` and no noise. +- `SineGen` runs in float64. +- The source STFT is the canonical float64 STFT described above. + +```sh +python tools/paradee/tools/dump_paradee_reference.py dump --model-dir --paradee-src --out ref +cmake -B build -DLLAMA_BUILD_TESTS=ON -DPARADEE_GGUF=$PWD/paradee-8m-v1.0-f32.gguf -DPARADEE_REF_DIR=$PWD/ref +ctest --test-dir build -R paradee --output-on-failure +``` + +| Test | Needs | Gates | +|---|---|---| +| `test-paradee-phase-lock` | nothing (committed fixture) | Lock within 1e-4 of `phase_lock.lock()` evaluated in float64; no further from the shipped float32 `phase_lock.lock()` than float32 is from float64; pass-through, length and cancel behaviour | +| `test-paradee-api` | `PARADEE_GGUF` | ids, vocabulary and length errors, input validation, determinism, speed, cancellation at several points | +| `test-paradee-parity` | `PARADEE_GGUF`, `PARADEE_REF_DIR` | integer durations identical; bert/d/dur/F0/N/asr within 1e-3 × RMS; source (from the reference F0) and decoder output (from the reference asr/F0/N/source) within 1e-3 × RMS; lock alone within 1e-4 of float64 `phase_lock.lock()`; final waveform SNR ≥ 35 dB | + +The model-backed tests exit 77 (CTest SKIP) when the model or the reference is not configured. + +Each acoustic stage in the parity test gets the reference's inputs. The harmonic source integrates F0 +over the whole utterance, so F0's float32 rounding (about 1e-6 relative) would otherwise show up as a +source phase error and be charged to the wrong stage. The end-to-end SNR covers the whole chain. + +The shipped float32 `phase_lock.lock()` differs from its own float64 evaluation by up to about 1e-3, at +bins where the source is nearly silent. No independent implementation can match it within 1e-4. The gate +therefore uses the float64 evaluation and reports the float32 distance. diff --git a/tools/paradee/include/paradee.h b/tools/paradee/include/paradee.h new file mode 100644 index 000000000..b8799ec71 --- /dev/null +++ b/tools/paradee/include/paradee.h @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: MIT +// +// paradee.h — Paradee-8M v1.0 text-to-speech, native in the elizaOS llama.cpp +// fork (tools/paradee). +// +// Paradee (sahilmahendrakar/Paradee-8M-v1.0, Apache-2.0) is Kokoro-82M's +// StyleTTS-2 + iSTFTNet code at smaller widths, distilled to the single +// af_heart voice. 8.07M parameters, 24 kHz output. The upstream runtime is an +// ONNX graph; this port runs the PyTorch weights (converted by +// convert_paradee_pt_to_gguf.py) through the generalised tools/kokoro layers +// plus a native implementation of the model's phase-locking filter. No ONNX. +// +// misaki phonemes (UTF-8) +// -> paradee_phonemes_to_ids : [0] + vocab ids + [0], at most 512 ids +// -> text side : ALBERT (6 x 256) -> duration encoder -> durations +// (round(dur / speed), min 1) -> F0 / N curves; +// TextEncoder -> asr_proj MLP (192 -> 512) +// -> decoder : AdaIN res blocks (256) -> iSTFTNet generator (128 -> 64 -> 32) +// with a deterministic harmonic source (no random phase, +// no additive noise, as in tools/kokoro) +// -> phase lock : in voiced frames, the 2-8 kHz phase follows the +// harmonic source (STFT 1024 / hop 256, 33-frame smoothing) +// -> 24 kHz mono float PCM +// +// Input phonemes must be misaki's spelling (Kokoro's G2P); eSpeak IPA must be +// converted to misaki spelling first. + +#pragma once + +#include +#include +#include +#include +#include + +namespace eliza_paradee { + +struct paradee_model; +struct paradee_model_deleter { void operator()(paradee_model *) const noexcept; }; +using paradee_model_ptr = std::unique_ptr; + +// Input contract of Paradee-8M v1.0: at most 512 ids, the pad (0) at each end. +constexpr int PARADEE_MAX_INPUT_IDS = 512; +constexpr int PARADEE_PAD_ID = 0; + +enum paradee_status { + PARADEE_OK = 0, + PARADEE_E_INVALID_ARG = 1, // null model, bad ids, bad speed, malformed input + PARADEE_E_UNKNOWN_SYMBOL = 2, // a phoneme outside the model vocabulary + PARADEE_E_TOO_LONG = 3, // more than 510 phonemes (512 ids with the pads) + PARADEE_E_RUNTIME = 4, // forward pass failed + PARADEE_CANCELLED = 5, // the cancel callback returned true + PARADEE_E_OOM = 6, // allocation failed + PARADEE_E_CANCELLED = PARADEE_CANCELLED, // alias +}; + +const char * paradee_status_str(paradee_status st) noexcept; + +// Load a Paradee GGUF written by convert_paradee_pt_to_gguf.py. The file must +// declare general.architecture == "paradee"; every hparam the graph reads is +// required. Returns nullptr and sets `err` on failure. +paradee_model_ptr paradee_load_model(const std::string & gguf_path, std::string & err) noexcept; + +// 24000 for Paradee-8M v1.0 (0 for a null model). +int paradee_sample_rate(const paradee_model * model) noexcept; + +// Map a misaki phoneme string (UTF-8, one vocabulary symbol per code point) +// to model input ids: [0] + ids + [0]. Rejects empty input, invalid UTF-8, +// any symbol outside the vocabulary (PARADEE_E_UNKNOWN_SYMBOL, the code point +// is named in `err`) and more than 510 symbols (PARADEE_E_TOO_LONG). No +// symbol is dropped silently. +paradee_status paradee_phonemes_to_ids( + const paradee_model * model, + const std::string & misaki_utf8, + std::vector & ids, + std::string & err) noexcept; + +using paradee_cancel_fn = bool (*)(void * user); +using paradee_cancel_cb = paradee_cancel_fn; // alias + +// The same mapping without the pads and without the length limit, for +// callers that split long phoneme strings into model-sized chunks: ids are +// the vocabulary ids of every symbol, in order. Rejects empty input, invalid +// UTF-8 and symbols outside the vocabulary exactly like +// paradee_phonemes_to_ids. +paradee_status paradee_phonemes_to_symbol_ids( + const paradee_model * model, + const std::string & misaki_utf8, + std::vector & ids, + std::string & err) noexcept; + +// Synthesize from input ids (with the pad at each end, 3..512 ids, every id +// in [0, n_token)). `speed` > 0 and finite (1.0 = native rate; larger is +// faster). `cancel` (optional) is polled between stages and inside the +// generator loops; when it returns true the call returns PARADEE_CANCELLED +// with `pcm` empty. Calls on one model are serialised. +paradee_status paradee_synthesize_ids( + const paradee_model * model, + const int32_t * ids, size_t n, + float speed, + paradee_cancel_fn cancel, void * user, + std::vector & pcm, + std::string & err) noexcept; + +// Convenience form of paradee_synthesize_ids for callers that keep options +// and audio in structs. +struct paradee_synth_options { + float speed = 1.0f; + paradee_cancel_fn cancel = nullptr; + void * cancel_user_data = nullptr; +}; + +struct paradee_audio { + std::vector samples; + int sample_rate = 24000; +}; + +paradee_status paradee_synthesize( + const paradee_model * model, + const std::vector & input_ids, + const paradee_synth_options & opts, + paradee_audio & out, + std::string & err) noexcept; + +// --------------------------------------------------------------------------- +// Parity / diagnostics. Stage outputs of one synthesis, in the reference +// dump's layouts (see tools/dump_paradee_reference.py). +// --------------------------------------------------------------------------- +struct paradee_stages { + int T_phon = 0; // number of input ids + int T_frame = 0; // sum of the integer durations + int d_model = 0, hid = 0, sty = 0, asr_dim = 0; + std::vector bert; // [T_phon, d_model] ALBERT last hidden state + std::vector d; // [T_phon, hid+sty] duration-encoder output + std::vector dur; // [T_phon] sigmoid-sum durations / speed + std::vector dur_int; // [T_phon] clamp(round(dur), min 1) + std::vector F0; // [2*T_frame] + std::vector N; // [2*T_frame] + std::vector asr; // [asr_dim, T_frame] aligned asr features (channel-major) + std::vector dec; // [2*T_frame*300] generator output before the phase lock + std::vector src; // [2*T_frame*300] harmonic source (m_source output) + std::vector wav; // final waveform +}; + +// paradee_synthesize_ids that also fills `stages` (when non-null). +paradee_status paradee_synthesize_ids_staged( + const paradee_model * model, + const int32_t * ids, size_t n, + float speed, + paradee_cancel_fn cancel, void * user, + std::vector & pcm, + std::string & err, + paradee_stages * stages) noexcept; + +// Decoder + generator + phase lock from given text-side outputs (the parity +// gate feeds the reference's asr / F0 / N here to test the acoustic half on +// its own). asr is [asr_dim, T_frame] channel-major, F0 and N are +// [2*T_frame]. `src` (optional, [2*T_frame*300]) replaces the harmonic +// source the generator would compute. Fills stages->dec / src / wav (stages +// may not be null). +paradee_status paradee_decode_staged( + const paradee_model * model, + const float * asr, int asr_dim, int T_frame, + const float * F0, const float * N, + const std::vector * src, + paradee_cancel_fn cancel, void * user, + paradee_stages * stages, + std::string & err) noexcept; + +// Phase-locking filter parameters (read from the GGUF; defaults are v1.0). +struct paradee_lock_params { + int n_fft = 1024; + int hop = 256; + float band_lo_hz = 2000.0f; + float band_hi_hz = 8000.0f; + int smooth_frames = 33; // L + float voiced_f0_hz = 60.0f; + int f0_hop = 300; // samples per F0 value + int sample_rate = 24000; +}; + +// The filter alone (training/scripts/phase_lock.py lock()): s = generator +// output, src = its harmonic source, f0 = the F0 curve at f0_hop samples per +// value. out has min(ns, nsrc) samples. Returns false with out = s when the +// parameters are invalid, and false with out empty when `cancel` stopped it. +// Signals no longer than n_fft/2 pass through unchanged (as torch.stft +// cannot reflect-pad them). +bool paradee_phase_lock( + const float * s, size_t ns, + const float * src, size_t nsrc, + const float * f0, size_t nf0, + const paradee_lock_params & p, + std::vector & out, + paradee_cancel_fn cancel = nullptr, void * user = nullptr); + +const paradee_lock_params * paradee_get_lock_params(const paradee_model * model) noexcept; + +} // namespace eliza_paradee diff --git a/tools/paradee/src/paradee-phase-lock.cpp b/tools/paradee/src/paradee-phase-lock.cpp new file mode 100644 index 000000000..b8f419dcf --- /dev/null +++ b/tools/paradee/src/paradee-phase-lock.cpp @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: MIT +// +// paradee-phase-lock.cpp — Paradee's parameter-free phase-locking filter. +// +// Port of training/scripts/phase_lock.py lock() (sahilmahendrakar/paradee, +// Apache-2.0, commit 9c8b4d75), the filter the Paradee-8M v1.0 graph applies +// after the decoder (export_paradee.py Lock, L = 33): +// +// S = STFT(s), E = STFT(src) n_fft 1024, hop 256, periodic Hann, +// centre frames with reflect padding +// voiced frame j <=> f0[min(j*hop / f0_hop, len-1)] > 60 Hz +// R = S * conj(E) / max(|E|, 1e-6) +// R~ = R smoothed over frames with hann(L+2)[1:-1] / sum (zero-padded) +// in voiced frames, for bins with 2 kHz <= f < 8 kHz: +// phase = angle(E) + angle(R~), magnitude = |S| +// out = iSTFT (window-envelope normalised, length = len(s)) +// +// The inverse is applied to the change only (out = s + iSTFT(delta)), as the +// exported graph does, so bins outside the band and unvoiced frames pass +// through untouched. Everything runs in double with a radix-2 FFT; the two +// real forward transforms share one complex FFT. + +#include "paradee.h" + +#include +#include +#include +#include +#include + +namespace eliza_paradee { + +namespace { + +using cd = std::complex; +constexpr double K_PI = 3.14159265358979323846; + +// In-place iterative radix-2 FFT (n a power of two). inverse = conjugate +// twiddles, no 1/n scaling. +class Fft { +public: + explicit Fft(int n) : n_(n), rev_((size_t) n), tw_((size_t) n / 2) { + int bits = 0; + while ((1 << bits) < n) ++bits; + for (int i = 0; i < n; ++i) { + int r = 0; + for (int b = 0; b < bits; ++b) if (i & (1 << b)) r |= 1 << (bits - 1 - b); + rev_[(size_t) i] = r; + } + for (int k = 0; k < n / 2; ++k) { + const double a = -2.0 * K_PI * (double) k / (double) n; + tw_[(size_t) k] = cd(std::cos(a), std::sin(a)); + } + } + void run(std::vector & a, bool inverse) const { + const int n = n_; + for (int i = 0; i < n; ++i) { + const int r = rev_[(size_t) i]; + if (i < r) std::swap(a[(size_t) i], a[(size_t) r]); + } + for (int len = 2; len <= n; len <<= 1) { + const int half = len >> 1, step = n / len; + for (int i = 0; i < n; i += len) { + for (int j = 0; j < half; ++j) { + cd w = tw_[(size_t) (j * step)]; + if (inverse) w = std::conj(w); + const cd u = a[(size_t) (i + j)]; + const cd v = a[(size_t) (i + j + half)] * w; + a[(size_t) (i + j)] = u + v; + a[(size_t) (i + j + half)] = u - v; + } + } + } + } +private: + int n_; + std::vector rev_; + std::vector tw_; +}; + +// unit(z) = z / |z|, with angle(0) = 0 as torch.angle defines it. +inline cd unit(cd z) { + const double m = std::abs(z); + return m > 0.0 ? z / m : cd(1.0, 0.0); +} + +} // namespace + +bool paradee_phase_lock( + const float * s, size_t ns, + const float * src, size_t nsrc, + const float * f0, size_t nf0, + const paradee_lock_params & p, + std::vector & out, + paradee_cancel_fn cancel, void * user) { + const size_t k = std::min(ns, nsrc); + out.assign(s, s + k); + const int N = p.n_fft, hop = p.hop, L = p.smooth_frames; + if (!s || !src || !f0 || nf0 == 0 || N < 4 || (N & (N - 1)) != 0 || hop <= 0 || + L < 1 || p.f0_hop <= 0 || p.sample_rate <= 0) { + return false; + } + const size_t pad = (size_t) N / 2; + // torch.stft(center=True) needs reflect padding narrower than the signal. + if (k <= pad) return true; + + // Band bins: FR = bin * sr / n_fft, lo <= FR < hi. + const int n_bins = N / 2 + 1; + int b_lo = n_bins, b_hi = 0; // [b_lo, b_hi) + for (int b = 0; b < n_bins; ++b) { + const double fr = (double) b * (double) p.sample_rate / (double) N; + if (fr >= p.band_lo_hz && fr < p.band_hi_hz) { + b_lo = std::min(b_lo, b); + b_hi = std::max(b_hi, b + 1); + } + } + if (b_lo >= b_hi) return true; + const int nb = b_hi - b_lo; + + // Reflect-padded signals. + const size_t np_len = k + 2 * pad; + std::vector ps(np_len), pe(np_len); + for (size_t i = 0; i < k; ++i) { ps[pad + i] = s[i]; pe[pad + i] = src[i]; } + for (size_t i = 0; i < pad; ++i) { + ps[pad - 1 - i] = s[i + 1]; pe[pad - 1 - i] = src[i + 1]; + ps[pad + k + i] = s[k - 2 - i]; pe[pad + k + i] = src[k - 2 - i]; + } + + // Periodic Hann window (torch.hann_window(N)). + std::vector w((size_t) N); + for (int i = 0; i < N; ++i) w[(size_t) i] = 0.5 - 0.5 * std::cos(2.0 * K_PI * (double) i / (double) N); + + const size_t nf = 1 + k / (size_t) hop; + Fft fft(N); + + // Forward STFTs of the band bins: S and E share one complex FFT of + // z = s + i*e: S[b] = (Z[b] + conj(Z[N-b])) / 2, E[b] = (Z[b] - conj(Z[N-b])) / 2i. + std::vector S(nf * (size_t) nb), E(nf * (size_t) nb); + std::vector z((size_t) N); + for (size_t j = 0; j < nf; ++j) { + if (cancel && (j & 31) == 0 && cancel(user)) { out.clear(); return false; } + const size_t off = j * (size_t) hop; + for (int n = 0; n < N; ++n) { + z[(size_t) n] = cd(ps[off + (size_t) n] * w[(size_t) n], pe[off + (size_t) n] * w[(size_t) n]); + } + fft.run(z, false); + for (int b = b_lo; b < b_hi; ++b) { + const cd zb = z[(size_t) b]; + const cd zm = std::conj(z[(size_t) ((N - b) % N)]); + S[j * (size_t) nb + (size_t) (b - b_lo)] = (zb + zm) * 0.5; + E[j * (size_t) nb + (size_t) (b - b_lo)] = (zb - zm) * cd(0.0, -0.5); + } + } + + // Voiced frames. + std::vector voiced(nf, 0); + bool any_voiced = false; + for (size_t j = 0; j < nf; ++j) { + const size_t fi = std::min((j * (size_t) hop) / (size_t) p.f0_hop, nf0 - 1); + voiced[j] = f0[fi] > p.voiced_f0_hz; + any_voiced = any_voiced || voiced[j]; + } + if (!any_voiced) return true; + + // R = S conj(E) / max(|E|, 1e-6), all frames (the smoothing reads + // unvoiced neighbours too). + std::vector R(nf * (size_t) nb); + for (size_t i = 0; i < R.size(); ++i) { + R[i] = S[i] * std::conj(E[i]) / std::max(std::abs(E[i]), 1e-6); + } + + // Smoothing kernel: hann_window(L + 2) (periodic)[1:-1], normalised. + std::vector ker((size_t) L); + double ksum = 0.0; + for (int m = 0; m < L; ++m) { + ker[(size_t) m] = 0.5 - 0.5 * std::cos(2.0 * K_PI * (double) (m + 1) / (double) (L + 2)); + ksum += ker[(size_t) m]; + } + for (double & v : ker) v /= ksum; + const int half = L / 2; + + // Window envelope sum_j w^2(n - j*hop) over the padded signal. + std::vector env(np_len, 0.0); + for (size_t j = 0; j < nf; ++j) { + const size_t off = j * (size_t) hop; + for (int n = 0; n < N; ++n) env[off + (size_t) n] += w[(size_t) n] * w[(size_t) n]; + } + + // Inverse STFT of the change in voiced frames, overlap-added. + std::vector ola(np_len, 0.0); + std::vector dz((size_t) N); + for (size_t j = 0; j < nf; ++j) { + if (!voiced[j]) continue; + if (cancel && (j & 31) == 0 && cancel(user)) { out.clear(); return false; } + std::fill(dz.begin(), dz.end(), cd(0.0, 0.0)); + for (int bi = 0; bi < nb; ++bi) { + cd sm(0.0, 0.0); + for (int m = 0; m < L; ++m) { + const long jj = (long) j + m - half; + if (jj < 0 || jj >= (long) nf) continue; + sm += ker[(size_t) m] * R[(size_t) jj * (size_t) nb + (size_t) bi]; + } + const cd Sv = S[j * (size_t) nb + (size_t) bi]; + const cd Ev = E[j * (size_t) nb + (size_t) bi]; + const cd locked = std::abs(Sv) * unit(Ev) * unit(sm); + const cd d = locked - Sv; + const int b = b_lo + bi; + dz[(size_t) b] += d; + if (b != 0 && b != N / 2) dz[(size_t) (N - b)] += std::conj(d); + } + fft.run(dz, true); + const size_t off = j * (size_t) hop; + for (int n = 0; n < N; ++n) { + ola[off + (size_t) n] += dz[(size_t) n].real() / (double) N * w[(size_t) n]; + } + } + for (size_t i = 0; i < k; ++i) { + const double e = env[pad + i]; + out[i] = (float) ((double) s[i] + (e > 1e-11 ? ola[pad + i] / e : 0.0)); + } + return true; +} + +} // namespace eliza_paradee diff --git a/tools/paradee/src/paradee.cpp b/tools/paradee/src/paradee.cpp new file mode 100644 index 000000000..640fafbbc --- /dev/null +++ b/tools/paradee/src/paradee.cpp @@ -0,0 +1,635 @@ +// SPDX-License-Identifier: MIT +// +// paradee.cpp — Paradee-8M v1.0 loader and synthesis (see include/paradee.h). +// +// The text side and decoder are Kokoro's modules at smaller widths, so this +// file only binds the GGUF to the generalised tools/kokoro layers +// (predictor_forward / decoder_forward) with the student-specific options: +// +// - two learned styles of different sizes (text 32, decoder 16) instead of +// one 256-d voice row, +// - the asr_proj GELU MLP (192 -> 512 -> 512) between the text encoder and +// the decoder, +// - the trained InstanceNorm affine inside every AdaIN1d, +// - torch.round (half to even) for the integer durations, +// - the canonical source STFT (exact zeros for near-empty bins), +// - the phase-locking filter after the generator. + +#include "paradee.h" + +#include "kokoro-decoder.h" +#include "kokoro-layers.h" +#include "kokoro-model-internal.h" +#include "kokoro-predictor.h" + +#include "ggml.h" +#include "gguf.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace eliza_paradee { + +using eliza_kokoro::DecoderConfig; +using eliza_kokoro::PredictorConfig; +using eliza_kokoro::PredictorOut; + +const char * paradee_status_str(paradee_status st) noexcept { + switch (st) { + case PARADEE_OK: return "ok"; + case PARADEE_E_INVALID_ARG: return "invalid argument"; + case PARADEE_E_UNKNOWN_SYMBOL: return "phoneme outside the vocabulary"; + case PARADEE_E_TOO_LONG: return "too many phonemes"; + case PARADEE_E_RUNTIME: return "runtime error"; + case PARADEE_CANCELLED: return "cancelled"; + case PARADEE_E_OOM: return "out of memory"; + } + return "unknown"; +} + +struct paradee_hparams { + int text_n_layer = 0, text_n_head = 0, text_d_model = 0, text_d_ff = 0; + int text_vocab_size = 0, text_max_position = 0; + int hidden_dim = 0, predictor_n_layer = 0, predictor_max_dur = 0, text_encoder_kernel = 0; + int style_text_dim = 0, style_decoder_dim = 0, asr_dim = 0; + int up_rates[2] = { 0, 0 }; + int istft_hop = 0; + int sample_rate = 0; + int pad_id = 0, max_ids = 0; +}; + +struct paradee_model { + paradee_hparams hp; + paradee_lock_params lock; + std::unordered_map vocab; // code point -> id + gguf_context * gguf = nullptr; + ggml_context * file_ctx = nullptr; // tensors as stored + ggml_context * ctx = nullptr; // all-F32 view the layers read + std::vector style_text, style_dec; + std::mutex mu; +}; + +void paradee_model_deleter::operator()(paradee_model * m) const noexcept { + if (!m) return; + if (m->ctx && m->ctx != m->file_ctx) ggml_free(m->ctx); + if (m->file_ctx) ggml_free(m->file_ctx); + if (m->gguf) gguf_free(m->gguf); + delete m; +} + +namespace { + +bool read_i32(gguf_context * g, const char * key, int & out, std::string & err) { + const int64_t id = gguf_find_key(g, key); + if (id < 0) { err = std::string("missing GGUF key ") + key; return false; } + switch (gguf_get_kv_type(g, id)) { + case GGUF_TYPE_INT32: out = gguf_get_val_i32(g, id); return true; + case GGUF_TYPE_UINT32: out = (int) gguf_get_val_u32(g, id); return true; + default: err = std::string("GGUF key ") + key + " is not an integer"; return false; + } +} + +bool read_f32(gguf_context * g, const char * key, float & out, std::string & err) { + const int64_t id = gguf_find_key(g, key); + if (id < 0) { err = std::string("missing GGUF key ") + key; return false; } + if (gguf_get_kv_type(g, id) != GGUF_TYPE_FLOAT32) { err = std::string("GGUF key ") + key + " is not f32"; return false; } + out = gguf_get_val_f32(g, id); + return true; +} + +bool read_i32_array(gguf_context * g, const char * key, std::vector & out, std::string & err) { + const int64_t id = gguf_find_key(g, key); + if (id < 0) { err = std::string("missing GGUF key ") + key; return false; } + if (gguf_get_kv_type(g, id) != GGUF_TYPE_ARRAY || gguf_get_arr_type(g, id) != GGUF_TYPE_INT32) { + err = std::string("GGUF key ") + key + " is not an int32 array"; + return false; + } + const size_t n = gguf_get_arr_n(g, id); + const int32_t * data = (const int32_t *) gguf_get_arr_data(g, id); + out.assign(data, data + n); + return true; +} + +// Decode one UTF-8 code point at s[i]; advances i. Returns false on invalid input. +bool utf8_next(const std::string & s, size_t & i, uint32_t & cp) { + const unsigned char c = (unsigned char) s[i]; + int len = 0; + if (c < 0x80) { cp = c; len = 1; } + else if ((c & 0xE0) == 0xC0) { cp = c & 0x1F; len = 2; } + else if ((c & 0xF0) == 0xE0) { cp = c & 0x0F; len = 3; } + else if ((c & 0xF8) == 0xF0) { cp = c & 0x07; len = 4; } + else return false; + if (i + (size_t) len > s.size()) return false; + for (int k = 1; k < len; ++k) { + const unsigned char cc = (unsigned char) s[i + (size_t) k]; + if ((cc & 0xC0) != 0x80) return false; + cp = (cp << 6) | (cc & 0x3F); + } + // Reject overlong forms, surrogates and out-of-range values. + if ((len == 2 && cp < 0x80) || (len == 3 && cp < 0x800) || (len == 4 && cp < 0x10000) || + cp > 0x10FFFF || (cp >= 0xD800 && cp <= 0xDFFF)) { + return false; + } + i += (size_t) len; + return true; +} + +bool load_vocab(gguf_context * g, int n_token, std::unordered_map & vocab, std::string & err) { + const int64_t sid = gguf_find_key(g, "paradee.vocab.symbols"); + std::vector ids; + if (sid < 0 || gguf_get_kv_type(g, sid) != GGUF_TYPE_ARRAY || gguf_get_arr_type(g, sid) != GGUF_TYPE_STRING) { + err = "missing GGUF string array paradee.vocab.symbols"; + return false; + } + if (!read_i32_array(g, "paradee.vocab.ids", ids, err)) return false; + const size_t n = gguf_get_arr_n(g, sid); + if (n != ids.size() || n == 0) { err = "paradee.vocab.symbols / ids length mismatch"; return false; } + for (size_t i = 0; i < n; ++i) { + const std::string sym = gguf_get_arr_str(g, sid, i); + size_t pos = 0; + uint32_t cp = 0; + if (sym.empty() || !utf8_next(sym, pos, cp) || pos != sym.size()) { + err = "vocabulary symbol " + std::to_string(i) + " is not one code point"; + return false; + } + const int id = ids[i]; + if (id <= 0 || id >= n_token) { err = "vocabulary id out of range"; return false; } + if (!vocab.emplace(cp, id).second) { err = "duplicate vocabulary symbol"; return false; } + } + return true; +} + +std::string codepoint_label(uint32_t cp) { + char buf[32]; + std::snprintf(buf, sizeof(buf), "U+%04X", (unsigned) cp); + return buf; +} + +} // namespace + +paradee_model_ptr paradee_load_model(const std::string & gguf_path, std::string & err) noexcept { + err.clear(); + try { + paradee_model_ptr m(new paradee_model()); + gguf_init_params params = { /* no_alloc = */ false, /* ctx = */ &m->file_ctx }; + m->gguf = gguf_init_from_file(gguf_path.c_str(), params); + if (!m->gguf || !m->file_ctx) { + err = "gguf_init_from_file failed for '" + gguf_path + "'"; + return nullptr; + } + gguf_context * g = m->gguf; + { + const int64_t id = gguf_find_key(g, "general.architecture"); + const char * arch = (id >= 0 && gguf_get_kv_type(g, id) == GGUF_TYPE_STRING) ? gguf_get_val_str(g, id) : ""; + if (std::string(arch) != "paradee") { + err = std::string("expected general.architecture='paradee', got '") + arch + "'"; + return nullptr; + } + } + paradee_hparams & h = m->hp; + std::vector rates; + bool ok = + read_i32(g, "paradee.text.n_layer", h.text_n_layer, err) && + read_i32(g, "paradee.text.n_head", h.text_n_head, err) && + read_i32(g, "paradee.text.d_model", h.text_d_model, err) && + read_i32(g, "paradee.text.d_ff", h.text_d_ff, err) && + read_i32(g, "paradee.text.vocab_size", h.text_vocab_size, err) && + read_i32(g, "paradee.text.max_position", h.text_max_position, err) && + read_i32(g, "paradee.hidden_dim", h.hidden_dim, err) && + read_i32(g, "paradee.predictor.n_layer", h.predictor_n_layer, err) && + read_i32(g, "paradee.predictor.max_dur", h.predictor_max_dur, err) && + read_i32(g, "paradee.text_encoder.kernel", h.text_encoder_kernel, err) && + read_i32(g, "paradee.style.text_dim", h.style_text_dim, err) && + read_i32(g, "paradee.style.decoder_dim", h.style_decoder_dim, err) && + read_i32(g, "paradee.asr.dim", h.asr_dim, err) && + read_i32_array(g, "paradee.decoder.upsample_rates", rates, err) && + read_i32(g, "paradee.decoder.istft_hop", h.istft_hop, err) && + read_i32(g, "paradee.audio.sample_rate", h.sample_rate, err) && + read_i32(g, "paradee.input.pad_id", h.pad_id, err) && + read_i32(g, "paradee.input.max_ids", h.max_ids, err) && + read_i32(g, "paradee.lock.n_fft", m->lock.n_fft, err) && + read_i32(g, "paradee.lock.hop", m->lock.hop, err) && + read_i32(g, "paradee.lock.smooth_frames", m->lock.smooth_frames, err) && + read_i32(g, "paradee.lock.f0_hop", m->lock.f0_hop, err) && + read_f32(g, "paradee.lock.voiced_f0_hz", m->lock.voiced_f0_hz, err); + if (!ok) return nullptr; + int band_lo = 0, band_hi = 0; + if (!read_i32(g, "paradee.lock.band_lo_hz", band_lo, err) || + !read_i32(g, "paradee.lock.band_hi_hz", band_hi, err)) { + return nullptr; + } + m->lock.band_lo_hz = (float) band_lo; + m->lock.band_hi_hz = (float) band_hi; + m->lock.sample_rate = h.sample_rate; + if (rates.size() != 2 || h.pad_id != 0 || h.max_ids < 3 || h.max_ids > h.text_max_position || + h.text_n_head <= 0 || h.text_d_model % h.text_n_head != 0 || h.sample_rate <= 0) { + err = "unsupported Paradee hparams"; + return nullptr; + } + h.up_rates[0] = rates[0]; + h.up_rates[1] = rates[1]; + if (!load_vocab(g, h.text_vocab_size, m->vocab, err)) return nullptr; + + // All-F32 working context (the converter's default output is already + // F32; an F16 file is widened once here). + bool all_f32 = true; + const int64_t n_tensors = gguf_get_n_tensors(g); + size_t f32_bytes = 0; + for (int64_t i = 0; i < n_tensors; ++i) { + ggml_tensor * t = ggml_get_tensor(m->file_ctx, gguf_get_tensor_name(g, i)); + if (!t) continue; + if (t->type != GGML_TYPE_F32) all_f32 = false; + f32_bytes += GGML_PAD((size_t) ggml_nelements(t) * sizeof(float), GGML_MEM_ALIGN); + } + if (all_f32) { + m->ctx = m->file_ctx; + } else { + ggml_init_params p = { f32_bytes + (size_t) (n_tensors + 1) * ggml_tensor_overhead(), nullptr, false }; + m->ctx = ggml_init(p); + if (!m->ctx) { err = "ggml_init failed"; return nullptr; } + for (int64_t i = 0; i < n_tensors; ++i) { + const char * name = gguf_get_tensor_name(g, i); + ggml_tensor * src = ggml_get_tensor(m->file_ctx, name); + if (!src) continue; + ggml_tensor * dst = ggml_new_tensor(m->ctx, GGML_TYPE_F32, ggml_n_dims(src), src->ne); + ggml_set_name(dst, name); + const int64_t n = ggml_nelements(src); + if (src->type == GGML_TYPE_F32) { + std::memcpy(dst->data, src->data, (size_t) n * sizeof(float)); + } else if (src->type == GGML_TYPE_F16) { + ggml_fp16_to_fp32_row((const ggml_fp16_t *) src->data, (float *) dst->data, n); + } else { + err = std::string("unsupported tensor type for '") + name + "'"; + return nullptr; + } + } + } + + ggml_tensor * st = ggml_get_tensor(m->ctx, "paradee.style.text"); + ggml_tensor * sd = ggml_get_tensor(m->ctx, "paradee.style.decoder"); + if (!st || !sd || ggml_nelements(st) != h.style_text_dim || ggml_nelements(sd) != h.style_decoder_dim) { + err = "missing or mis-sized paradee.style.text / paradee.style.decoder"; + return nullptr; + } + m->style_text.assign((const float *) st->data, (const float *) st->data + h.style_text_dim); + m->style_dec.assign((const float *) sd->data, (const float *) sd->data + h.style_decoder_dim); + + const char * required[] = { + "paradee.bert.token_embd.weight", "paradee.bert.layer.attn_q.weight", + "paradee.bert_encoder.weight", "paradee.predictor.duration_proj.weight", + "paradee.predictor.F0_proj.weight", "paradee.text_encoder.embd.weight", + "paradee.asr_proj.0.weight", "paradee.asr_proj.1.weight", + "paradee.decoder.encode.conv1.weight", "paradee.gen.conv_post.weight", + }; + for (const char * name : required) { + if (!ggml_get_tensor(m->ctx, name)) { + err = std::string("required tensor missing: ") + name; + return nullptr; + } + } + ggml_tensor * be = ggml_get_tensor(m->ctx, "paradee.bert_encoder.weight"); + ggml_tensor * ap = ggml_get_tensor(m->ctx, "paradee.asr_proj.0.weight"); + ggml_tensor * tok = ggml_get_tensor(m->ctx, "paradee.bert.token_embd.weight"); + if ((int) be->ne[1] != h.hidden_dim || (int) be->ne[0] != h.text_d_model || + (int) ap->ne[1] != h.asr_dim || (int) tok->ne[1] != h.text_vocab_size) { + err = "tensor shapes do not match the Paradee hparams"; + return nullptr; + } + return m; + } catch (const std::exception & e) { + err = std::string("paradee_load_model: ") + e.what(); + return nullptr; + } +} + +int paradee_sample_rate(const paradee_model * model) noexcept { + return model ? model->hp.sample_rate : 0; +} + +const paradee_lock_params * paradee_get_lock_params(const paradee_model * model) noexcept { + return model ? &model->lock : nullptr; +} + +namespace { + +// Vocabulary ids of every symbol; `max_symbols` = 0 means no limit. +paradee_status map_symbols( + const paradee_model * model, + const std::string & misaki_utf8, + size_t max_symbols, + bool pads, + std::vector & ids, + std::string & err) noexcept { + err.clear(); + ids.clear(); + if (!model) { err = "null model"; return PARADEE_E_INVALID_ARG; } + if (misaki_utf8.empty()) { err = "empty phoneme string"; return PARADEE_E_INVALID_ARG; } + try { + if (pads) ids.push_back(model->hp.pad_id); + const size_t lead = ids.size(); + size_t i = 0; + while (i < misaki_utf8.size()) { + uint32_t cp = 0; + const size_t at = i; + if (!utf8_next(misaki_utf8, i, cp)) { + err = "invalid UTF-8 at byte " + std::to_string(at); + ids.clear(); + return PARADEE_E_INVALID_ARG; + } + const auto it = model->vocab.find(cp); + if (it == model->vocab.end()) { + err = "phoneme " + codepoint_label(cp) + " at byte " + std::to_string(at) + + " is not in the Paradee vocabulary"; + ids.clear(); + return PARADEE_E_UNKNOWN_SYMBOL; + } + if (max_symbols && ids.size() - lead >= max_symbols) { + err = "more than " + std::to_string(max_symbols) + " phonemes"; + ids.clear(); + return PARADEE_E_TOO_LONG; + } + ids.push_back(it->second); + } + if (pads) ids.push_back(model->hp.pad_id); + } catch (const std::bad_alloc &) { + err = "out of memory"; + ids.clear(); + return PARADEE_E_OOM; + } catch (const std::exception & e) { + err = e.what(); + ids.clear(); + return PARADEE_E_RUNTIME; + } + return PARADEE_OK; +} + +} // namespace + +paradee_status paradee_phonemes_to_ids( + const paradee_model * model, + const std::string & misaki_utf8, + std::vector & ids, + std::string & err) noexcept { + if (!model) { ids.clear(); err = "null model"; return PARADEE_E_INVALID_ARG; } + return map_symbols(model, misaki_utf8, (size_t) model->hp.max_ids - 2, /*pads*/ true, ids, err); +} + +paradee_status paradee_phonemes_to_symbol_ids( + const paradee_model * model, + const std::string & misaki_utf8, + std::vector & ids, + std::string & err) noexcept { + return map_symbols(model, misaki_utf8, 0, /*pads*/ false, ids, err); +} + +namespace { + +// Layer strategies Paradee opts into (kokoro-layers.h): per-tap SGEMM +// convolutions and the polynomial Snake activation. PARADEE_EXACT_LAYERS=1 +// turns them off (the arithmetic Kokoro-82M uses), for A/B checks. +eliza_kokoro::detail::KokoroLayerOptions fast_layers() { + eliza_kokoro::detail::KokoroLayerOptions o; + const char * exact = std::getenv("PARADEE_EXACT_LAYERS"); + const bool fast = !(exact && exact[0] == '1'); + o.tap_gemm_conv = fast; + o.fast_snake = fast; + return o; +} + +struct cancel_state { + paradee_cancel_fn fn; + void * user; +}; + +bool cancel_trampoline(void * p) { + const cancel_state * c = (const cancel_state *) p; + return c && c->fn && c->fn(c->user); +} + +} // namespace + +namespace { + +// Decoder + generator + phase lock. Caller holds model->mu. +paradee_status decode_locked( + const paradee_model * model, + const float * asr_ct, int A, int T, + const float * F0, const float * N, + paradee_cancel_fn cancel, void * user, + std::vector & dec, std::vector & src, std::vector & pcm, + std::string & err, const std::vector * src_in = nullptr) { + const paradee_hparams & h = model->hp; + cancel_state cs{ cancel, user }; + DecoderConfig dc; + dc.ns = "paradee"; + dc.instnorm_affine = true; + dc.canonical_source_stft = true; + dc.precise_source = true; + dc.up_rates[0] = h.up_rates[0]; + dc.up_rates[1] = h.up_rates[1]; + dc.istft_hop = h.istft_hop; + dc.har_source_out = &src; + dc.har_source_in = src_in; + dc.cancel = cancel ? cancel_trampoline : nullptr; + dc.cancel_user = &cs; + if (!eliza_kokoro::decoder_forward(model->ctx, dc, asr_ct, A, T, F0, N, + model->style_dec.data(), dec, err)) { + if (dc.cancelled) { err = "cancelled"; return PARADEE_CANCELLED; } + if (err.empty()) err = "decoder failed"; + return PARADEE_E_RUNTIME; + } + if (!paradee_phase_lock(dec.data(), dec.size(), src.data(), src.size(), + F0, (size_t) 2 * (size_t) T, model->lock, pcm, cancel, user)) { + if (cancel && cancel(user)) { err = "cancelled"; return PARADEE_CANCELLED; } + err = "phase lock rejected its parameters"; + return PARADEE_E_RUNTIME; + } + if (cancel && cancel(user)) { err = "cancelled"; return PARADEE_CANCELLED; } + return PARADEE_OK; +} + +} // namespace + +paradee_status paradee_synthesize_ids_staged( + const paradee_model * model, + const int32_t * ids, size_t n, + float speed, + paradee_cancel_fn cancel, void * user, + std::vector & pcm, + std::string & err, + paradee_stages * stages) noexcept { + err.clear(); + pcm.clear(); + if (!model) { err = "null model"; return PARADEE_E_INVALID_ARG; } + const paradee_hparams & h = model->hp; + if (!ids || n < 3 || n > (size_t) h.max_ids) { + err = "expected 3.." + std::to_string(h.max_ids) + " input ids, got " + std::to_string(n); + return n > (size_t) h.max_ids ? PARADEE_E_TOO_LONG : PARADEE_E_INVALID_ARG; + } + if (ids[0] != h.pad_id || ids[n - 1] != h.pad_id) { + err = "input ids must start and end with the pad id 0"; + return PARADEE_E_INVALID_ARG; + } + for (size_t i = 0; i < n; ++i) { + if (ids[i] < 0 || ids[i] >= h.text_vocab_size) { + err = "input id " + std::to_string(ids[i]) + " out of range"; + return PARADEE_E_INVALID_ARG; + } + } + if (!std::isfinite(speed) || speed <= 0.0f) { + err = "speed must be finite and > 0"; + return PARADEE_E_INVALID_ARG; + } + + try { + std::lock_guard lk(const_cast(model)->mu); + eliza_kokoro::detail::KokoroLayerOptionsScope layer_scope(fast_layers()); + cancel_state cs{ cancel, user }; + if (cancel && cancel(user)) { err = "cancelled"; return PARADEE_CANCELLED; } + + // 1. Text side. + PredictorConfig pc; + pc.ns = "paradee"; + pc.text_n_layer = h.text_n_layer; + pc.text_n_head = h.text_n_head; + pc.text_d_model = h.text_d_model; + pc.text_d_ff = h.text_d_ff; + pc.n_layer = h.predictor_n_layer; + pc.max_dur = h.predictor_max_dur; + pc.text_kernel = h.text_encoder_kernel; + pc.style = model->style_text.data(); + pc.torch_round = true; + pc.instnorm_affine = true; + pc.asr_proj_mlp = true; + pc.keep_bert = stages != nullptr; + pc.cancel = cancel ? cancel_trampoline : nullptr; + pc.cancel_user = &cs; + PredictorOut pred; + const std::vector id_vec(ids, ids + n); + if (!eliza_kokoro::predictor_forward(model->ctx, pc, id_vec, speed, pred, err)) { + if (pred.cancelled) { pcm.clear(); return PARADEE_CANCELLED; } + if (err.empty()) err = "text side failed"; + return PARADEE_E_RUNTIME; + } + if (pred.sty != h.style_text_dim || pred.asr_dim != h.asr_dim || pred.T_frame <= 0) { + err = "text side produced unexpected widths"; + return PARADEE_E_RUNTIME; + } + const int T = pred.T_frame; + const int A = pred.asr_dim; + std::vector asr_ct((size_t) A * (size_t) T); + for (int t = 0; t < T; ++t) { + const float * row = pred.asr.data() + (size_t) t * A; + for (int c = 0; c < A; ++c) asr_ct[(size_t) c * (size_t) T + t] = row[c]; + } + if (cancel && cancel(user)) { err = "cancelled"; return PARADEE_CANCELLED; } + + // 2. Decoder + generator, 3. phase lock. + std::vector src, dec; + const paradee_status dst = decode_locked(model, asr_ct.data(), A, T, pred.F0_pred.data(), + pred.N_pred.data(), cancel, user, dec, src, pcm, err); + if (dst != PARADEE_OK) { pcm.clear(); return dst; } + + if (stages) { + stages->T_phon = pred.T_phon; + stages->T_frame = T; + stages->d_model = h.text_d_model; + stages->hid = pred.hid; + stages->sty = pred.sty; + stages->asr_dim = A; + stages->bert = std::move(pred.bert); + stages->d = std::move(pred.d); + stages->dur = std::move(pred.duration); + stages->dur_int = std::move(pred.pred_dur); + stages->F0 = std::move(pred.F0_pred); + stages->N = std::move(pred.N_pred); + stages->asr = std::move(asr_ct); + stages->dec = std::move(dec); + stages->src = std::move(src); + stages->wav = pcm; + } + return PARADEE_OK; + } catch (const std::bad_alloc &) { + pcm.clear(); + err = "paradee synthesis: out of memory"; + return PARADEE_E_OOM; + } catch (const std::exception & e) { + pcm.clear(); + err = std::string("paradee synthesis: ") + e.what(); + return PARADEE_E_RUNTIME; + } +} + +paradee_status paradee_synthesize_ids( + const paradee_model * model, + const int32_t * ids, size_t n, + float speed, + paradee_cancel_fn cancel, void * user, + std::vector & pcm, + std::string & err) noexcept { + return paradee_synthesize_ids_staged(model, ids, n, speed, cancel, user, pcm, err, nullptr); +} + +} // namespace eliza_paradee + +namespace eliza_paradee { + +paradee_status paradee_synthesize( + const paradee_model * model, + const std::vector & input_ids, + const paradee_synth_options & opts, + paradee_audio & out, + std::string & err) noexcept { + out.sample_rate = model ? model->hp.sample_rate : 24000; + return paradee_synthesize_ids_staged(model, input_ids.data(), input_ids.size(), opts.speed, + opts.cancel, opts.cancel_user_data, out.samples, err, nullptr); +} + +} // namespace eliza_paradee + +namespace eliza_paradee { + +paradee_status paradee_decode_staged( + const paradee_model * model, + const float * asr, int asr_dim, int T_frame, + const float * F0, const float * N, + const std::vector * src, + paradee_cancel_fn cancel, void * user, + paradee_stages * stages, + std::string & err) noexcept { + err.clear(); + if (!model || !asr || !F0 || !N || !stages || T_frame <= 0 || asr_dim != model->hp.asr_dim) { + err = "invalid decode arguments"; + return PARADEE_E_INVALID_ARG; + } + try { + std::lock_guard lk(const_cast(model)->mu); + eliza_kokoro::detail::KokoroLayerOptionsScope layer_scope(fast_layers()); + stages->T_frame = T_frame; + stages->asr_dim = asr_dim; + const paradee_hparams & h = model->hp; + const size_t n_src = (size_t) T_frame * 2 * (size_t) (h.up_rates[0] * h.up_rates[1] * h.istft_hop); + if (src && src->size() != n_src) { + err = "src must have " + std::to_string(n_src) + " samples"; + return PARADEE_E_INVALID_ARG; + } + return decode_locked(model, asr, asr_dim, T_frame, F0, N, cancel, user, + stages->dec, stages->src, stages->wav, err, src); + } catch (const std::bad_alloc &) { + err = "out of memory"; + return PARADEE_E_OOM; + } catch (const std::exception & e) { + err = e.what(); + return PARADEE_E_RUNTIME; + } +} + +} // namespace eliza_paradee diff --git a/tools/paradee/tests/CMakeLists.txt b/tools/paradee/tests/CMakeLists.txt new file mode 100644 index 000000000..68c83b44c --- /dev/null +++ b/tools/paradee/tests/CMakeLists.txt @@ -0,0 +1,45 @@ +# tools/paradee/tests/CMakeLists.txt +# +# test-paradee-phase-lock self-contained (committed fixture), always runs. +# test-paradee-api needs the Paradee GGUF. +# test-paradee-parity needs the GGUF and a reference dump from +# tools/dump_paradee_reference.py. +# +# The model-backed tests read PARADEE_GGUF / PARADEE_REF_DIR from the +# environment, or from the cache variables of the same names, and report +# SKIP (exit 77) when they are not set. See tools/paradee/README.md. + +set(PARADEE_GGUF "" CACHE FILEPATH "Paradee GGUF for test-paradee-api / test-paradee-parity") +set(PARADEE_REF_DIR "" CACHE PATH "dump_paradee_reference.py output for test-paradee-parity") + +set(PARADEE_FIXTURE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/fixtures) + +add_executable(test-paradee-phase-lock test_paradee_phase_lock.cpp) +target_link_libraries(test-paradee-phase-lock PRIVATE paradee_lib) +target_compile_definitions(test-paradee-phase-lock PRIVATE PARADEE_FIXTURE_DIR="${PARADEE_FIXTURE_DIR}") +add_test(NAME test-paradee-phase-lock COMMAND test-paradee-phase-lock) + +add_executable(test-paradee-api test_paradee_api.cpp) +target_link_libraries(test-paradee-api PRIVATE paradee_lib) +add_test(NAME test-paradee-api COMMAND test-paradee-api) + +add_executable(test-paradee-parity test_paradee_parity.cpp) +target_link_libraries(test-paradee-parity PRIVATE paradee_lib) +target_include_directories(test-paradee-parity PRIVATE ${PROJECT_SOURCE_DIR}/vendor) +target_compile_definitions(test-paradee-parity PRIVATE PARADEE_FIXTURE_DIR="${PARADEE_FIXTURE_DIR}") +add_test(NAME test-paradee-parity COMMAND test-paradee-parity) + +set(_paradee_env "") +if (PARADEE_GGUF) + list(APPEND _paradee_env "PARADEE_GGUF=${PARADEE_GGUF}") +endif() +if (PARADEE_REF_DIR) + list(APPEND _paradee_env "PARADEE_REF_DIR=${PARADEE_REF_DIR}") +endif() +foreach(t test-paradee-api test-paradee-parity) + set_tests_properties(${t} PROPERTIES SKIP_RETURN_CODE 77) + if (_paradee_env) + set_tests_properties(${t} PROPERTIES ENVIRONMENT "${_paradee_env}") + endif() +endforeach() +set_tests_properties(test-paradee-parity PROPERTIES TIMEOUT 900) diff --git a/tools/paradee/tests/fixtures/phase_lock_synthetic.f32 b/tools/paradee/tests/fixtures/phase_lock_synthetic.f32 new file mode 100644 index 0000000000000000000000000000000000000000..04897e800535c10749abe6422c8aa89015ef17ea GIT binary patch literal 76800 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%0ABFmA+v{Il=1Lguc`Y(y{-tc{lOkL^; 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 literal 0 HcmV?d00001 diff --git a/tools/paradee/tests/fixtures/sentences.misaki.json b/tools/paradee/tests/fixtures/sentences.misaki.json new file mode 100644 index 000000000..7af532586 --- /dev/null +++ b/tools/paradee/tests/fixtures/sentences.misaki.json @@ -0,0 +1,758 @@ +{ + "source": "sahilmahendrakar/Paradee-8M-v1.0@c7f83f86c04e84d0ae94cbe1aad7618e0544f80a samples/sentences.txt", + "g2p": "misaki en.G2P(trf=False, british=False, fallback=EspeakFallback(british=False), unk='')", + "versions": { + "misaki": "0.9.4", + "spacy": "3.8.16", + "en_core_web_sm": "3.8.0", + "espeak-ng": "1.52.0" + }, + "sentences": [ + { + "index": 1, + "text": "As of August 2015, there were 169 proposed targets for these goals and 304 proposed indicators to show compliance.", + "phonemes": "ˌæz ʌv ˈɔɡəst twˈɛnti fˌɪftˈin, ðɛɹ wɜɹ wˈʌn hˈʌndɹəd sˈɪksti nˈIn pɹəpˈOzd tˈɑɹɡəts fɔɹ ðiz ɡˈOlz ænd θɹˈi hˈʌndɹəd fˈɔɹ pɹəpˈOzd ˈɪndəkˌATəɹz tə ʃˈO kəmplˈIəns.", + "ids": [ + 0, + 157, + 72, + 68, + 16, + 138, + 64, + 16, + 156, + 76, + 92, + 83, + 61, + 62, + 16, + 62, + 65, + 156, + 86, + 56, + 62, + 51, + 16, + 48, + 157, + 102, + 48, + 62, + 156, + 51, + 56, + 3, + 16, + 81, + 86, + 123, + 16, + 65, + 87, + 123, + 16, + 65, + 156, + 138, + 56, + 16, + 50, + 156, + 138, + 56, + 46, + 123, + 83, + 46, + 16, + 61, + 156, + 102, + 53, + 61, + 62, + 51, + 16, + 56, + 156, + 25, + 56, + 16, + 58, + 123, + 83, + 58, + 156, + 31, + 68, + 46, + 16, + 62, + 156, + 69, + 123, + 92, + 83, + 62, + 61, + 16, + 48, + 76, + 123, + 16, + 81, + 51, + 68, + 16, + 92, + 156, + 31, + 54, + 68, + 16, + 72, + 56, + 46, + 16, + 119, + 123, + 156, + 51, + 16, + 50, + 156, + 138, + 56, + 46, + 123, + 83, + 46, + 16, + 48, + 156, + 76, + 123, + 16, + 58, + 123, + 83, + 58, + 156, + 31, + 68, + 46, + 16, + 156, + 102, + 56, + 46, + 83, + 53, + 157, + 24, + 36, + 83, + 123, + 68, + 16, + 62, + 83, + 16, + 131, + 156, + 31, + 16, + 53, + 83, + 55, + 58, + 54, + 156, + 25, + 83, + 56, + 61, + 4, + 0 + ] + }, + { + "index": 2, + "text": "White dwarf stars, if they have a near companion, may then become Type Ia supernovae.", + "phonemes": "wˈIt dwˈɔɹf stˈɑɹz, ɪf ðA hæv ɐ nˌɪɹ kəmpˈænjən, mˈA ðˈɛn bəkˈʌm tˈIp ˈIə sˌupəɹnˈOvˌi.", + "ids": [ + 0, + 65, + 156, + 25, + 62, + 16, + 46, + 65, + 156, + 76, + 123, + 48, + 16, + 61, + 62, + 156, + 69, + 123, + 68, + 3, + 16, + 102, + 48, + 16, + 81, + 24, + 16, + 50, + 72, + 64, + 16, + 70, + 16, + 56, + 157, + 102, + 123, + 16, + 53, + 83, + 55, + 58, + 156, + 72, + 56, + 52, + 83, + 56, + 3, + 16, + 55, + 156, + 24, + 16, + 81, + 156, + 86, + 56, + 16, + 44, + 83, + 53, + 156, + 138, + 55, + 16, + 62, + 156, + 25, + 58, + 16, + 156, + 25, + 83, + 16, + 61, + 157, + 63, + 58, + 83, + 123, + 56, + 156, + 31, + 64, + 157, + 51, + 4, + 0 + ] + }, + { + "index": 3, + "text": "According to Herodotus, this was because they had decided in council that they could not beat the Allies in a naval battle.", + "phonemes": "əkˈɔɹdɪŋ tə hɛɹˈɑdəTəs, ðɪs wʌz bəkˈʌz ðA hæd dəsˈIdᵻd ɪn kˈWnsᵊl ðæt ðA kʊd nˌɑt bˈit ði ˈælˌIz ɪn ɐ nˈAvᵊl bˈæTᵊl.", + "ids": [ + 0, + 83, + 53, + 156, + 76, + 123, + 46, + 102, + 112, + 16, + 62, + 83, + 16, + 50, + 86, + 123, + 156, + 69, + 46, + 83, + 36, + 83, + 61, + 3, + 16, + 81, + 102, + 61, + 16, + 65, + 138, + 68, + 16, + 44, + 83, + 53, + 156, + 138, + 68, + 16, + 81, + 24, + 16, + 50, + 72, + 46, + 16, + 46, + 83, + 61, + 156, + 25, + 46, + 177, + 46, + 16, + 102, + 56, + 16, + 53, + 156, + 39, + 56, + 61, + 42, + 54, + 16, + 81, + 72, + 62, + 16, + 81, + 24, + 16, + 53, + 135, + 46, + 16, + 56, + 157, + 69, + 62, + 16, + 44, + 156, + 51, + 62, + 16, + 81, + 51, + 16, + 156, + 72, + 54, + 157, + 25, + 68, + 16, + 102, + 56, + 16, + 70, + 16, + 56, + 156, + 24, + 64, + 42, + 54, + 16, + 44, + 156, + 72, + 36, + 42, + 54, + 4, + 0 + ] + }, + { + "index": 4, + "text": "In 1997 Hurlbut's career focused on light as applied to photography and film, and he owned a lighting business in Pasadena, California.", + "phonemes": "ɪn nˌIntˈin nˈIndi sˈɛvən hˈɜɹlbʌts kəɹˈɪɹ fˈOkəst ˌɔn lˈIt æz əplˈId tə fətˈɑɡɹəfi ænd fˈɪlm, ænd hi ˈOnd ɐ lˈITɪŋ bˈɪznəs ɪn pˌæsədˈinə, kˌæləfˈɔɹnjə.", + "ids": [ + 0, + 102, + 56, + 16, + 56, + 157, + 25, + 56, + 62, + 156, + 51, + 56, + 16, + 56, + 156, + 25, + 56, + 46, + 51, + 16, + 61, + 156, + 86, + 64, + 83, + 56, + 16, + 50, + 156, + 87, + 123, + 54, + 44, + 138, + 62, + 61, + 16, + 53, + 83, + 123, + 156, + 102, + 123, + 16, + 48, + 156, + 31, + 53, + 83, + 61, + 62, + 16, + 157, + 76, + 56, + 16, + 54, + 156, + 25, + 62, + 16, + 72, + 68, + 16, + 83, + 58, + 54, + 156, + 25, + 46, + 16, + 62, + 83, + 16, + 48, + 83, + 62, + 156, + 69, + 92, + 123, + 83, + 48, + 51, + 16, + 72, + 56, + 46, + 16, + 48, + 156, + 102, + 54, + 55, + 3, + 16, + 72, + 56, + 46, + 16, + 50, + 51, + 16, + 156, + 31, + 56, + 46, + 16, + 70, + 16, + 54, + 156, + 25, + 36, + 102, + 112, + 16, + 44, + 156, + 102, + 68, + 56, + 83, + 61, + 16, + 102, + 56, + 16, + 58, + 157, + 72, + 61, + 83, + 46, + 156, + 51, + 56, + 83, + 3, + 16, + 53, + 157, + 72, + 54, + 83, + 48, + 156, + 76, + 123, + 56, + 52, + 83, + 4, + 0 + ] + }, + { + "index": 5, + "text": "Harvey ended with a century on his Ashes debut, scoring 112 from 183 balls in an innings noted for powerful driving on both sides of the wicket.", + "phonemes": "hˈɑɹvi ˈɛndᵻd wɪð ɐ sˈɛnʧəɹi ˌɔn hɪz ˈæʃᵻz dAbjˈu, skˈɔɹɪŋ wˈʌn hˈʌndɹəd twˈɛlv fɹʌm wˈʌn hˈʌndɹəd ˈATi θɹˈi bˈɔlz ɪn ɐn ˈɪnɪŋz nˈOTᵻd fɔɹ pˈWəɹfᵊl dɹˈIvɪŋ ˌɔn bˈOθ sˈIdz ʌv ðə wˈɪkət.", + "ids": [ + 0, + 50, + 156, + 69, + 123, + 64, + 51, + 16, + 156, + 86, + 56, + 46, + 177, + 46, + 16, + 65, + 102, + 81, + 16, + 70, + 16, + 61, + 156, + 86, + 56, + 133, + 83, + 123, + 51, + 16, + 157, + 76, + 56, + 16, + 50, + 102, + 68, + 16, + 156, + 72, + 131, + 177, + 68, + 16, + 46, + 24, + 44, + 52, + 156, + 63, + 3, + 16, + 61, + 53, + 156, + 76, + 123, + 102, + 112, + 16, + 65, + 156, + 138, + 56, + 16, + 50, + 156, + 138, + 56, + 46, + 123, + 83, + 46, + 16, + 62, + 65, + 156, + 86, + 54, + 64, + 16, + 48, + 123, + 138, + 55, + 16, + 65, + 156, + 138, + 56, + 16, + 50, + 156, + 138, + 56, + 46, + 123, + 83, + 46, + 16, + 156, + 24, + 36, + 51, + 16, + 119, + 123, + 156, + 51, + 16, + 44, + 156, + 76, + 54, + 68, + 16, + 102, + 56, + 16, + 70, + 56, + 16, + 156, + 102, + 56, + 102, + 112, + 68, + 16, + 56, + 156, + 31, + 36, + 177, + 46, + 16, + 48, + 76, + 123, + 16, + 58, + 156, + 39, + 83, + 123, + 48, + 42, + 54, + 16, + 46, + 123, + 156, + 25, + 64, + 102, + 112, + 16, + 157, + 76, + 56, + 16, + 44, + 156, + 31, + 119, + 16, + 61, + 156, + 25, + 46, + 68, + 16, + 138, + 64, + 16, + 81, + 83, + 16, + 65, + 156, + 102, + 53, + 83, + 62, + 4, + 0 + ] + } + ] +} diff --git a/tools/paradee/tests/test_paradee_api.cpp b/tools/paradee/tests/test_paradee_api.cpp new file mode 100644 index 000000000..fe51758aa --- /dev/null +++ b/tools/paradee/tests/test_paradee_api.cpp @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: MIT +// +// test-paradee-api — the eliza_paradee C++ API contract on the real model: +// loading, phoneme mapping (pads, unknown symbols, length limit, UTF-8), +// input validation, determinism, speed, and cancellation (before the call, +// between stages and inside the generator loops). +// +// Needs PARADEE_GGUF (environment); exits 77 (CTest SKIP) without it. + +#include "paradee.h" + +#include +#include +#include +#include +#include +#include +#include + +using namespace eliza_paradee; + +static int failures = 0; +#define CHECK(cond, ...) do { if (!(cond)) { std::fprintf(stderr, "FAIL %s:%d: ", __FILE__, __LINE__); \ + std::fprintf(stderr, __VA_ARGS__); std::fprintf(stderr, "\n"); ++failures; } } while (0) + +struct counter { std::atomic polls{0}; int stop_after = 0; }; +static bool cancel_after(void * u) { + counter * c = (counter *) u; + return ++c->polls > c->stop_after; +} + +int main() { + const char * gguf = std::getenv("PARADEE_GGUF"); + if (!gguf || !*gguf) { + std::printf("test-paradee-api: PARADEE_GGUF not set, skipping\n"); + return 77; + } + std::string err; + + // Loader rejections. + CHECK(!paradee_load_model("/nonexistent/paradee.gguf", err) && !err.empty(), "missing file accepted"); + auto model = paradee_load_model(gguf, err); + if (!model) { std::fprintf(stderr, "load failed: %s\n", err.c_str()); return 1; } + CHECK(paradee_sample_rate(model.get()) == 24000, "sample rate %d", paradee_sample_rate(model.get())); + CHECK(paradee_sample_rate(nullptr) == 0, "null model sample rate"); + const paradee_lock_params * lp = paradee_get_lock_params(model.get()); + CHECK(lp && lp->n_fft == 1024 && lp->hop == 256 && lp->smooth_frames == 33 && lp->f0_hop == 300 && + lp->band_lo_hz == 2000.0f && lp->band_hi_hz == 8000.0f && lp->voiced_f0_hz == 60.0f, + "lock parameters not read from the GGUF"); + + // Phonemes -> ids. + std::vector ids; + CHECK(paradee_phonemes_to_ids(model.get(), "həlˈO wˈɜɹld.", ids, err) == PARADEE_OK, "%s", err.c_str()); + const std::vector want = { 0, 50, 83, 54, 156, 31, 16, 65, 156, 87, 123, 54, 46, 4, 0 }; + CHECK(ids == want, "hello world ids differ (got %zu ids)", ids.size()); + CHECK(paradee_phonemes_to_ids(model.get(), "hɛlo#", ids, err) == PARADEE_E_UNKNOWN_SYMBOL && + err.find("U+0023") != std::string::npos && ids.empty(), "unknown symbol: %s", err.c_str()); + CHECK(paradee_phonemes_to_ids(model.get(), "ɡ", ids, err) == PARADEE_OK && ids.size() == 3 && ids[1] == 92, + "U+0261 script g"); + CHECK(paradee_phonemes_to_ids(model.get(), "g", ids, err) == PARADEE_E_UNKNOWN_SYMBOL, "ASCII g is not in the vocab"); + CHECK(paradee_phonemes_to_ids(model.get(), "", ids, err) == PARADEE_E_INVALID_ARG, "empty accepted"); + CHECK(paradee_phonemes_to_ids(model.get(), std::string("a\xC3", 2), ids, err) == PARADEE_E_INVALID_ARG, + "truncated UTF-8 accepted"); + CHECK(paradee_phonemes_to_ids(model.get(), std::string("\xC0\xAF"), ids, err) == PARADEE_E_INVALID_ARG, + "overlong UTF-8 accepted"); + CHECK(paradee_phonemes_to_ids(nullptr, "a", ids, err) == PARADEE_E_INVALID_ARG, "null model"); + { + std::string ok510(510, 'a'), long511(511, 'a'); + CHECK(paradee_phonemes_to_ids(model.get(), ok510, ids, err) == PARADEE_OK && ids.size() == 512, + "510 phonemes must give 512 ids"); + CHECK(paradee_phonemes_to_ids(model.get(), long511, ids, err) == PARADEE_E_TOO_LONG && ids.empty(), + "511 phonemes accepted"); + } + + // Unpadded, unlimited form for chunkers. + { + std::vector sym; + CHECK(paradee_phonemes_to_symbol_ids(model.get(), "həlˈO wˈɜɹld.", sym, err) == PARADEE_OK && + sym == std::vector(want.begin() + 1, want.end() - 1), "symbol ids without pads"); + CHECK(paradee_phonemes_to_symbol_ids(model.get(), std::string(2000, 'a'), sym, err) == PARADEE_OK && + sym.size() == 2000, "symbol ids must not be length-limited"); + CHECK(paradee_phonemes_to_symbol_ids(model.get(), "a#", sym, err) == PARADEE_E_UNKNOWN_SYMBOL && sym.empty(), + "symbol ids must reject unknown symbols"); + } + + // Synthesis input validation. + std::vector pcm; + paradee_phonemes_to_ids(model.get(), "həlˈO wˈɜɹld.", ids, err); + { + std::vector nopad(ids.begin() + 1, ids.end()); + CHECK(paradee_synthesize_ids(model.get(), nopad.data(), nopad.size(), 1.0f, nullptr, nullptr, pcm, err) == + PARADEE_E_INVALID_ARG, "missing leading pad accepted"); + std::vector bad = ids; bad[3] = 178; + CHECK(paradee_synthesize_ids(model.get(), bad.data(), bad.size(), 1.0f, nullptr, nullptr, pcm, err) == + PARADEE_E_INVALID_ARG, "id 178 accepted"); + const int32_t two[2] = { 0, 0 }; + CHECK(paradee_synthesize_ids(model.get(), two, 2, 1.0f, nullptr, nullptr, pcm, err) == PARADEE_E_INVALID_ARG, + "2 ids accepted"); + std::vector big(513, 43); big.front() = 0; big.back() = 0; + CHECK(paradee_synthesize_ids(model.get(), big.data(), big.size(), 1.0f, nullptr, nullptr, pcm, err) == + PARADEE_E_TOO_LONG, "513 ids accepted"); + CHECK(paradee_synthesize_ids(model.get(), ids.data(), ids.size(), 0.0f, nullptr, nullptr, pcm, err) == + PARADEE_E_INVALID_ARG, "speed 0 accepted"); + CHECK(paradee_synthesize_ids(model.get(), ids.data(), ids.size(), NAN, nullptr, nullptr, pcm, err) == + PARADEE_E_INVALID_ARG, "speed NaN accepted"); + CHECK(paradee_synthesize_ids(nullptr, ids.data(), ids.size(), 1.0f, nullptr, nullptr, pcm, err) == + PARADEE_E_INVALID_ARG, "null model"); + } + + // Synthesis: deterministic, sane level, speed shortens. + std::vector a, b, fast; + CHECK(paradee_synthesize_ids(model.get(), ids.data(), ids.size(), 1.0f, nullptr, nullptr, a, err) == PARADEE_OK, + "%s", err.c_str()); + CHECK(paradee_synthesize_ids(model.get(), ids.data(), ids.size(), 1.0f, nullptr, nullptr, b, err) == PARADEE_OK, + "%s", err.c_str()); + CHECK(!a.empty() && a == b, "synthesis is not deterministic"); + CHECK(a.size() % 600 == 0, "length %zu is not a whole number of frames", a.size()); + double rms = 0, peak = 0; + for (float v : a) { rms += (double) v * v; peak = std::max(peak, (double) std::fabs(v)); } + rms = std::sqrt(rms / (double) a.size()); + CHECK(rms > 0.01 && rms < 0.3 && peak < 1.5, "level rms %.4f peak %.3f", rms, peak); + CHECK(paradee_synthesize_ids(model.get(), ids.data(), ids.size(), 1.5f, nullptr, nullptr, fast, err) == PARADEE_OK && + fast.size() < a.size(), "speed 1.5 not shorter (%zu vs %zu)", fast.size(), a.size()); + + // Convenience form gives the same audio. + { + paradee_synth_options opts; + paradee_audio audio; + CHECK(paradee_synthesize(model.get(), ids, opts, audio, err) == PARADEE_OK && audio.samples == a && + audio.sample_rate == 24000, "paradee_synthesize differs"); + } + + // Cancellation: before the call, after the text side, inside the generator. + { + int total_polls = 0; + counter count; count.stop_after = 1 << 30; + CHECK(paradee_synthesize_ids(model.get(), ids.data(), ids.size(), 1.0f, cancel_after, &count, pcm, err) == PARADEE_OK, + "%s", err.c_str()); + total_polls = count.polls.load(); + CHECK(total_polls >= 8, "only %d cancel polls in one synthesis", total_polls); + for (int stop : { 0, 2, total_polls / 2, total_polls - 2 }) { + counter c; c.stop_after = stop; + pcm.assign(10, 1.0f); + const paradee_status st = paradee_synthesize_ids(model.get(), ids.data(), ids.size(), 1.0f, cancel_after, &c, pcm, err); + CHECK(st == PARADEE_CANCELLED && pcm.empty(), "cancel after %d polls: status %s, %zu samples", + stop, paradee_status_str(st), pcm.size()); + } + } + + if (failures) { std::fprintf(stderr, "%d failure(s)\n", failures); return 1; } + std::printf("test-paradee-api: OK (%zu samples, rms %.4f)\n", a.size(), rms); + return 0; +} diff --git a/tools/paradee/tests/test_paradee_parity.cpp b/tools/paradee/tests/test_paradee_parity.cpp new file mode 100644 index 000000000..73b916312 --- /dev/null +++ b/tools/paradee/tests/test_paradee_parity.cpp @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: MIT +// +// test-paradee-parity — native Paradee against the pinned PyTorch reference +// (tools/dump_paradee_reference.py dump) on the misaki phonemes of the model +// card's samples/sentences.txt (fixtures/sentences.misaki.json). +// +// Gates, per sentence: +// ids paradee_phonemes_to_ids == the fixture's ids +// durations integer durations identical +// text side bert, d, dur, F0, N, asr: max|err| <= 1e-3 * RMS(reference) +// source harmonic source from the reference F0: max|err| <= 1e-3 * RMS +// decoder generator output from the reference asr / F0 / N / source: +// max|err| <= 1e-3 * RMS +// lock phase lock alone on the reference decoder output and source: +// max|err| <= 1e-4 against phase_lock.lock() in float64, and no +// further from the shipped float32 phase_lock.lock() than that +// float32 evaluation is from float64 (+1e-4) +// end to end final waveform SNR >= 35 dB against the reference +// +// Each acoustic stage is fed the reference's inputs, so an error is charged +// to the stage that made it: the harmonic source integrates F0 over seconds, +// and float32 rounding differences in F0 (~1e-6 relative) would otherwise +// show up as source phase differences. The end-to-end check covers the chain. +// +// Needs PARADEE_GGUF and PARADEE_REF_DIR (environment); exits 77 (CTest +// SKIP) without them. PARADEE_PARITY_OUT=

also writes the native +// waveforms (.wav.f32) for `dump_paradee_reference.py report`. + +#include "paradee.h" + +#include "nlohmann/json.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef PARADEE_FIXTURE_DIR +#error "PARADEE_FIXTURE_DIR must be defined" +#endif + +using json = nlohmann::json; +using namespace eliza_paradee; + +static int failures = 0; +#define CHECK(cond, ...) do { if (!(cond)) { std::fprintf(stderr, "FAIL %s:%d: ", __FILE__, __LINE__); \ + std::fprintf(stderr, __VA_ARGS__); std::fprintf(stderr, "\n"); ++failures; } } while (0) + +template +static std::vector read_raw(const std::string & path) { + std::ifstream f(path, std::ios::binary | std::ios::ate); + if (!f) { std::fprintf(stderr, "cannot read %s\n", path.c_str()); std::exit(1); } + std::vector v((size_t) f.tellg() / sizeof(T)); + f.seekg(0); + f.read((char *) v.data(), (std::streamsize) (v.size() * sizeof(T))); + return v; +} + +static json read_json(const std::string & path) { + std::ifstream f(path); + if (!f) { std::fprintf(stderr, "cannot read %s\n", path.c_str()); std::exit(1); } + std::stringstream ss; ss << f.rdbuf(); + return json::parse(ss.str()); +} + +struct err_stats { double max_abs = 0, rms_ref = 0, snr_db = 0; bool same_size = true; }; + +static err_stats compare(const std::vector & ref, const std::vector & got) { + err_stats s; + if (ref.size() != got.size() || ref.empty()) { s.same_size = false; return s; } + double e2 = 0, r2 = 0; + for (size_t i = 0; i < ref.size(); ++i) { + const double d = (double) got[i] - (double) ref[i]; + s.max_abs = std::max(s.max_abs, std::fabs(d)); + e2 += d * d; + r2 += (double) ref[i] * ref[i]; + } + s.rms_ref = std::sqrt(r2 / (double) ref.size()); + s.snr_db = e2 > 0 ? 10.0 * std::log10(r2 / e2) : 300.0; + return s; +} + +static void gate_stage(int sent, const char * name, const std::vector & ref, const std::vector & got) { + const err_stats s = compare(ref, got); + if (!s.same_size) { + CHECK(false, "sentence %d %s: size %zu != reference %zu", sent, name, got.size(), ref.size()); + return; + } + const double ratio = s.max_abs / std::max(s.rms_ref, 1e-30); + std::printf(" %-6s max|err| %9.3g rms %9.4g ratio %9.3g snr %6.1f dB\n", name, s.max_abs, s.rms_ref, ratio, s.snr_db); + CHECK(ratio <= 1e-3, "sentence %d %s: max|err| %.3g > 1e-3 * rms %.4g", sent, name, s.max_abs, s.rms_ref); +} + +int main() { + const char * gguf = std::getenv("PARADEE_GGUF"); + const char * ref_dir_c = std::getenv("PARADEE_REF_DIR"); + if (!gguf || !*gguf || !ref_dir_c || !*ref_dir_c) { + std::printf("test-paradee-parity: PARADEE_GGUF / PARADEE_REF_DIR not set, skipping\n"); + return 77; + } + const std::string ref_dir = ref_dir_c; + const char * out_dir = std::getenv("PARADEE_PARITY_OUT"); + + const json manifest = read_json(ref_dir + "/manifest.json"); + const json fixture = read_json(std::string(PARADEE_FIXTURE_DIR) + "/sentences.misaki.json"); + const float speed = manifest.value("speed", 1.0f); + + std::string err; + auto model = paradee_load_model(gguf, err); + if (!model) { std::fprintf(stderr, "load failed: %s\n", err.c_str()); return 1; } + const paradee_lock_params lock = *paradee_get_lock_params(model.get()); + + CHECK(manifest["sentences"].size() == fixture["sentences"].size(), "reference and fixture sentence counts differ"); + double total_audio = 0, total_secs = 0; + for (size_t k = 0; k < manifest["sentences"].size(); ++k) { + const json & m = manifest["sentences"][k]; + const json & fx = fixture["sentences"][k]; + const int sent = m["index"].get(); + const std::string phonemes = fx["phonemes"].get(); + auto file = [&](const char * name) { return ref_dir + "/" + m["files"][name]["file"].get(); }; + std::printf("sentence %d (%zu bytes of phonemes)\n", sent, phonemes.size()); + CHECK(m["phonemes"].get() == phonemes, "sentence %d: reference was dumped from other phonemes", sent); + + // ids + std::vector ids; + CHECK(paradee_phonemes_to_ids(model.get(), phonemes, ids, err) == PARADEE_OK, "%s", err.c_str()); + CHECK(ids == fx["ids"].get>(), "sentence %d: ids differ from the fixture", sent); + CHECK(ids == read_raw(file("ids")), "sentence %d: ids differ from the reference", sent); + + // full synthesis + paradee_stages st; + std::vector pcm; + const auto t0 = std::chrono::steady_clock::now(); + const paradee_status rc = paradee_synthesize_ids_staged(model.get(), ids.data(), ids.size(), speed, + nullptr, nullptr, pcm, err, &st); + const double secs = std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); + if (rc != PARADEE_OK) { CHECK(false, "sentence %d: synthesis failed: %s", sent, err.c_str()); continue; } + total_secs += secs; + total_audio += (double) pcm.size() / 24000.0; + + const std::vector ref_dur = read_raw(file("dur_int")); + CHECK(st.dur_int == ref_dur, "sentence %d: integer durations differ", sent); + std::printf(" durations identical: %s (%d frames)\n", st.dur_int == ref_dur ? "yes" : "NO", st.T_frame); + gate_stage(sent, "bert", read_raw(file("bert")), st.bert); + gate_stage(sent, "d", read_raw(file("d")), st.d); + gate_stage(sent, "dur", read_raw(file("dur")), st.dur); + gate_stage(sent, "F0", read_raw(file("F0")), st.F0); + gate_stage(sent, "N", read_raw(file("N")), st.N); + gate_stage(sent, "asr", read_raw(file("asr")), st.asr); + + // acoustic half, teacher-forced stage by stage + const std::vector ref_asr = read_raw(file("asr")); + const std::vector ref_F0 = read_raw(file("F0")); + const std::vector ref_N = read_raw(file("N")); + const std::vector ref_src = read_raw(file("src")); + const std::vector ref_dec = read_raw(file("dec")); + const std::vector ref_wav = read_raw(file("wav")); + const std::vector ref_l32 = read_raw(file("lock_f32")); + const int T = (int) ref_F0.size() / 2; + { + paradee_stages a; + CHECK(paradee_decode_staged(model.get(), ref_asr.data(), 512, T, ref_F0.data(), ref_N.data(), nullptr, + nullptr, nullptr, &a, err) == PARADEE_OK, "%s", err.c_str()); + gate_stage(sent, "src", ref_src, a.src); + paradee_stages b; + CHECK(paradee_decode_staged(model.get(), ref_asr.data(), 512, T, ref_F0.data(), ref_N.data(), &ref_src, + nullptr, nullptr, &b, err) == PARADEE_OK, "%s", err.c_str()); + gate_stage(sent, "dec", ref_dec, b.dec); + } + { + std::vector locked; + CHECK(paradee_phase_lock(ref_dec.data(), ref_dec.size(), ref_src.data(), ref_src.size(), + ref_F0.data(), ref_F0.size(), lock, locked), "lock rejected its input"); + const err_stats e64 = compare(ref_wav, locked); + const err_stats e32 = compare(ref_l32, locked); + const err_stats f32 = compare(ref_wav, ref_l32); + std::printf(" lock max|err| %9.3g vs float64 phase_lock (gate 1e-4); %9.3g vs float32 phase_lock " + "(float32 itself: %.3g)\n", e64.max_abs, e32.max_abs, f32.max_abs); + CHECK(e64.same_size && e64.max_abs <= 1e-4, "sentence %d lock: %.3g > 1e-4", sent, e64.max_abs); + CHECK(e32.same_size && e32.max_abs <= f32.max_abs + 1e-4, "sentence %d lock vs float32: %.3g > %.3g", + sent, e32.max_abs, f32.max_abs + 1e-4); + } + + // end to end + const err_stats e = compare(ref_wav, pcm); + std::printf(" end to end: snr %.1f dB, max|err| %.3g, %zu samples, %.3f s synth (%.1fx realtime)\n", + e.snr_db, e.max_abs, pcm.size(), secs, (double) pcm.size() / 24000.0 / secs); + CHECK(e.same_size && e.snr_db >= 35.0, "sentence %d: end-to-end SNR %.1f dB < 35 dB", sent, e.snr_db); + + if (out_dir && *out_dir) { + std::ofstream f(std::string(out_dir) + "/" + std::to_string(sent) + ".wav.f32", std::ios::binary); + f.write((const char *) pcm.data(), (std::streamsize) (pcm.size() * sizeof(float))); + } + } + std::printf("total: %.2f s of audio in %.2f s (%.1fx realtime)\n", total_audio, total_secs, total_audio / total_secs); + if (failures) { std::fprintf(stderr, "%d failure(s)\n", failures); return 1; } + std::printf("test-paradee-parity: OK\n"); + return 0; +} diff --git a/tools/paradee/tests/test_paradee_phase_lock.cpp b/tools/paradee/tests/test_paradee_phase_lock.cpp new file mode 100644 index 000000000..3017a672a --- /dev/null +++ b/tools/paradee/tests/test_paradee_phase_lock.cpp @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: MIT +// +// test-paradee-phase-lock — the native phase-locking filter against +// training/scripts/phase_lock.py lock() on deterministic synthetic input. +// +// fixtures/phase_lock_synthetic.f32 holds two outputs of the reference on the +// inputs built below (dump_paradee_reference.py lock-fixture): +// [0, n) phase_lock.lock() evaluated in float64 -> gate: max |err| <= 1e-4 +// [n, 2n) phase_lock.lock() as shipped (float32) -> gate: within float32's +// own distance from the float64 result (+1e-4) +// Also checks pass-through (unvoiced input, short input) and cancellation. + +#include "paradee.h" + +#include +#include +#include +#include +#include +#include + +#ifndef PARADEE_FIXTURE_DIR +#error "PARADEE_FIXTURE_DIR must be defined" +#endif + +static constexpr int N = 9600; +static constexpr double SR = 24000.0; +static constexpr double PI = 3.14159265358979323846; + +static void make_inputs(std::vector & s, std::vector & src, std::vector & f0) { + const int nf0 = N / 300; + std::vector f0d((size_t) nf0); + for (int j = 0; j < nf0; ++j) f0d[(size_t) j] = (j >= 12 && j < 16) ? 0.0 : 110.0 + 110.0 * j / (double) (nf0 - 1); + s.assign(N, 0.0f); src.assign(N, 0.0f); f0.assign((size_t) nf0, 0.0f); + for (int j = 0; j < nf0; ++j) f0[(size_t) j] = (float) f0d[(size_t) j]; + double ph = 0.0; + for (int t = 0; t < N; ++t) { + const double f = f0d[(size_t) (t / 300)]; + ph += 2 * PI * f / SR; + const bool voiced = f > 0; + src[(size_t) t] = (float) (std::tanh(0.8 * std::sin(ph)) * (voiced ? 1.0 : 0.0) + 0.01 * std::sin(0.37 * t)); + double acc = 0.0; + for (int h = 1; h < 40; ++h) { + if (f * h >= 11000) break; + acc += std::sin(h * ph + 0.002 * h * t / 7.0 + 0.5 * h) / h; + } + s[(size_t) t] = (float) (0.2 * acc * (voiced ? 1.0 : 0.3) + 0.003 * std::sin(1.3 * t)); + } +} + +static int failures = 0; +#define CHECK(cond, ...) do { if (!(cond)) { std::fprintf(stderr, "FAIL %s:%d: ", __FILE__, __LINE__); \ + std::fprintf(stderr, __VA_ARGS__); std::fprintf(stderr, "\n"); ++failures; } } while (0) + +static double max_abs(const std::vector & a, const float * b, size_t n) { + double m = 0; + for (size_t i = 0; i < n; ++i) m = std::max(m, std::fabs((double) a[i] - (double) b[i])); + return m; +} + +static bool cancel_now(void *) { return true; } + +int main() { + std::vector s, src, f0; + make_inputs(s, src, f0); + + std::vector ref((size_t) 2 * N); + { + std::ifstream in(std::string(PARADEE_FIXTURE_DIR) + "/phase_lock_synthetic.f32", std::ios::binary); + in.read((char *) ref.data(), (std::streamsize) (ref.size() * sizeof(float))); + if (!in) { std::fprintf(stderr, "cannot read phase_lock_synthetic.f32\n"); return 1; } + } + const float * ref64 = ref.data(); + const float * ref32 = ref.data() + N; + + eliza_paradee::paradee_lock_params p; // v1.0 defaults + std::vector out; + CHECK(eliza_paradee::paradee_phase_lock(s.data(), s.size(), src.data(), src.size(), f0.data(), f0.size(), p, out), + "lock returned false"); + CHECK(out.size() == (size_t) N, "output length %zu", out.size()); + const double e64 = max_abs(out, ref64, N); + const double e32 = max_abs(out, ref32, N); + double f32_floor = 0; + for (int i = 0; i < N; ++i) f32_floor = std::max(f32_floor, std::fabs((double) ref32[i] - ref64[i])); + double change = 0; + for (int i = 0; i < N; ++i) change += ((double) ref64[i] - s[(size_t) i]) * ((double) ref64[i] - s[(size_t) i]); + change = std::sqrt(change / N); + std::printf("lock vs phase_lock.lock float64: max|err| = %.3g (gate 1e-4)\n", e64); + std::printf("lock vs phase_lock.lock float32: max|err| = %.3g (float32 vs float64 itself: %.3g)\n", e32, f32_floor); + std::printf("the filter changes this input by rms %.4g\n", change); + CHECK(e64 <= 1e-4, "float64 reference error %.3g > 1e-4", e64); + CHECK(e32 <= f32_floor + 1e-4, "float32 reference error %.3g > %.3g", e32, f32_floor + 1e-4); + CHECK(change > 1e-3, "fixture does not exercise the filter"); + + // Unvoiced everywhere: output == input. + { + std::vector zf0(f0.size(), 0.0f), o2; + eliza_paradee::paradee_phase_lock(s.data(), s.size(), src.data(), src.size(), zf0.data(), zf0.size(), p, o2); + CHECK(max_abs(o2, s.data(), N) == 0.0, "unvoiced input changed"); + } + // Shorter than n_fft/2: passes through (torch cannot reflect-pad it). + { + std::vector o3; + CHECK(eliza_paradee::paradee_phase_lock(s.data(), 500, src.data(), 500, f0.data(), f0.size(), p, o3), + "short input rejected"); + CHECK(o3.size() == 500 && max_abs(o3, s.data(), 500) == 0.0, "short input changed"); + } + // Lengths differ: output has min(ns, nsrc) samples. + { + std::vector o4; + eliza_paradee::paradee_phase_lock(s.data(), N, src.data(), N - 1000, f0.data(), f0.size(), p, o4); + CHECK(o4.size() == (size_t) N - 1000, "min length %zu", o4.size()); + } + // Cancellation empties the output. + { + std::vector o5; + const bool ok = eliza_paradee::paradee_phase_lock(s.data(), s.size(), src.data(), src.size(), + f0.data(), f0.size(), p, o5, cancel_now, nullptr); + CHECK(!ok && o5.empty(), "cancel did not stop the filter"); + } + // Invalid parameters: false, output = input. + { + eliza_paradee::paradee_lock_params bad = p; + bad.n_fft = 1000; + std::vector o6; + CHECK(!eliza_paradee::paradee_phase_lock(s.data(), s.size(), src.data(), src.size(), f0.data(), f0.size(), bad, o6), + "non power-of-two n_fft accepted"); + CHECK(o6.size() == (size_t) N && max_abs(o6, s.data(), N) == 0.0, "rejected call changed the signal"); + } + + if (failures) { std::fprintf(stderr, "%d failure(s)\n", failures); return 1; } + std::printf("test-paradee-phase-lock: OK\n"); + return 0; +} diff --git a/tools/paradee/tools/dump_paradee_reference.py b/tools/paradee/tools/dump_paradee_reference.py new file mode 100644 index 000000000..3561d7836 --- /dev/null +++ b/tools/paradee/tools/dump_paradee_reference.py @@ -0,0 +1,561 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +""" +dump_paradee_reference.py - PyTorch reference for the tools/paradee parity gate. + +Three subcommands: + + phonemes Phonemize samples/sentences.txt with misaki 0.9.4 + (en.G2P(trf=False, british=False, fallback=EspeakFallback(british=False), unk=""), + the setup paradee/tts.py and kokoro.KPipeline(lang_code="a") use) and write + tests/fixtures/sentences.misaki.json. The fixture is committed; `dump` reads it. + + dump Run the pinned PyTorch Paradee (TextStudent 's+mlp' + StudentDecoder 'A' from + the pinned github.com/sahilmahendrakar/paradee checkout, kokoro 0.9.4 modules) + on each fixture sentence and write every stage plus manifest.json for + test-paradee-parity. + + lock-fixture Write tests/fixtures/phase_lock_synthetic.f32: phase_lock.lock() (float64 + and the unmodified float32) on deterministic synthetic input, for the + self-contained test-paradee-phase-lock. + + report Compare native waveforms (written by test-paradee-parity with + PARADEE_PARITY_OUT) with the deterministic reference and with the published, + seeded samples/*_paradee.wav: log-mel L1 (raw and DTW). With --whisper-cli, + also the word error rate of whisper.cpp on the native and the published audio. + Report only. + +The reference is deterministic. Three documented changes make it so and make it a +well-conditioned target; the native graph implements the same three: + + 1. SineGen: rand_ini = 0 and no additive noise (tools/kokoro does the same). + 2. SineGen runs in float64. In float32 the accumulated phase of the 9th harmonic + reaches ~1e5 rad in a few seconds, where one float32 step is ~1e-2 rad; float64 + makes the source a defined value rather than an accident of float32 rounding. + 3. The harmonic source's STFT (the generator's `har` input) is computed in float64 + and values below the DFT's rounding floor are set to exactly zero before the + phase is taken. Without noise, an unvoiced source is a constant, its STFT bins + 2..n_fft/2 are pure rounding noise, and their phase is otherwise arbitrary. + The inverse STFT is export_paradee.py's ConvSTFT.inverse (= torch.istft). + +The final waveform applies phase_lock.lock() in float64. lock_f32 is the unmodified +float32 phase_lock.lock() on the same decoder output, for the Lock-only check. + +usage: + dump_paradee_reference.py phonemes --model-dir HF_SNAPSHOT --out tools/paradee/tests/fixtures/sentences.misaki.json + dump_paradee_reference.py dump --model-dir HF_SNAPSHOT --paradee-src PARADEE_CHECKOUT --out REF_DIR + dump_paradee_reference.py report --model-dir HF_SNAPSHOT --ref REF_DIR --native NATIVE_DIR +""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib +import importlib.metadata +import json +import math +import sys +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).resolve().parent +PARADEE_DIR = HERE.parent +PINS = json.loads((PARADEE_DIR / "pins.json").read_text(encoding="utf-8")) +DEFAULT_FIXTURE = PARADEE_DIR / "tests" / "fixtures" / "sentences.misaki.json" +SR = 24000 + + +def sha256_file(p: Path) -> str: + return hashlib.sha256(p.read_bytes()).hexdigest() + + +def check_file(root: Path, rel: str, pin: dict | str) -> Path: + path = root / rel + want = pin if isinstance(pin, str) else pin["sha256"] + got = sha256_file(path) + if got != want: + raise SystemExit(f"{path}: sha256 {got} != pinned {want}") + return path + + +def check_versions(*names: str) -> None: + env = PINS["reference_env"] + for name in names: + got = importlib.metadata.version(name) + if got != env[name]: + raise SystemExit(f"{name} {got} != pinned {env[name]} (see pins.json reference_env)") + + +def read_sentences(model_dir: Path) -> list[str]: + path = check_file(model_dir, "samples/sentences.txt", PINS["files"]["samples/sentences.txt"]) + out = [] + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + num, _, text = line.partition(". ") # "1. As of August 2015, ..." + if not num.isdigit(): + raise SystemExit(f"unexpected sentences.txt line {line!r}") + out.append(text) + return out + + +# --------------------------------------------------------------------------- +# phonemes +# --------------------------------------------------------------------------- + +def cmd_phonemes(args) -> int: + check_versions("misaki", "spacy", "en_core_web_sm") + import espeakng_loader + if args.espeak_lib: + espeakng_loader.get_library_path = lambda: args.espeak_lib + if args.espeak_data: + espeakng_loader.get_data_path = lambda: args.espeak_data + from misaki import en, espeak + + fb = espeak.EspeakFallback(british=False) + g2p = en.G2P(trf=False, british=False, fallback=fb, unk="") + espeak_version = fb.backend.version() + vocab = json.loads(check_file(args.model_dir, "config.json", PINS["files"]["config.json"]).read_text(encoding="utf-8"))["vocab"] + items = [] + for i, text in enumerate(read_sentences(args.model_dir), 1): + ps, _ = g2p(text) + missing = sorted({c for c in ps if c not in vocab}) + if missing: + raise SystemExit(f"sentence {i}: phonemes outside the vocabulary: {missing}") + items.append({"index": i, "text": text, "phonemes": ps, + "ids": [0] + [vocab[c] for c in ps] + [0]}) + doc = { + "source": f"{PINS['model']['repo']}@{PINS['model']['revision']} samples/sentences.txt", + "g2p": "misaki en.G2P(trf=False, british=False, fallback=EspeakFallback(british=False), unk='')", + "versions": {"misaki": importlib.metadata.version("misaki"), "spacy": importlib.metadata.version("spacy"), + "en_core_web_sm": importlib.metadata.version("en_core_web_sm"), + "espeak-ng": ".".join(str(x) for x in espeak_version)}, + "sentences": items, + } + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(json.dumps(doc, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(f"wrote {args.out} sha256 {sha256_file(args.out)}") + return 0 + + +# --------------------------------------------------------------------------- +# reference model +# --------------------------------------------------------------------------- + +def load_reference(model_dir: Path, paradee_src: Path): + check_versions("torch", "kokoro", "misaki") + for rel, digest in PINS["source"]["files"].items(): + check_file(paradee_src, rel, digest) + for rel in ("pytorch/text_side.pt", "pytorch/decoder.pt"): + check_file(model_dir, rel, PINS["files"][rel]) + + import torch + import torch.nn.functional as F + sys.path.insert(0, str(paradee_src / "training" / "scripts")) + student = importlib.import_module("student") + student_decoder = importlib.import_module("student_decoder") + phase_lock = importlib.import_module("phase_lock") + from kokoro.istftnet import SineGen + from torch.nn.utils import remove_weight_norm + from torch.nn.utils.parametrize import is_parametrized, remove_parametrizations + + torch.set_grad_enabled(False) + + def strip(m): # export_paradee.py strip() + for x in m.modules(): + try: + remove_weight_norm(x) + except Exception: + pass + if is_parametrized(x, "weight"): + try: + remove_parametrizations(x, "weight", leave_parametrized=True) + except Exception: + pass + + # (1) + (2): deterministic float64 SineGen (kokoro 0.9.4 SineGen with + # flag_for_pulse=False, minus rand_ini and noise). + def f02sine_det(self, f0_values): + rad_values = (f0_values / self.sampling_rate) % 1 + rad_values = F.interpolate(rad_values.transpose(1, 2), scale_factor=1 / self.upsample_scale, + mode="linear").transpose(1, 2) + phase = torch.cumsum(rad_values, dim=1) * 2 * torch.pi + phase = F.interpolate(phase.transpose(1, 2) * self.upsample_scale, scale_factor=self.upsample_scale, + mode="linear").transpose(1, 2) + return torch.sin(phase) + + def sinegen_forward_det(self, f0): + assert not self.flag_for_pulse + f0d = f0.double() + fn = f0d * torch.arange(1, self.harmonic_num + 2, dtype=torch.float64)[None, None, :] + sine_waves = f02sine_det(self, fn) * self.sine_amp + uv = (f0d > self.voiced_threshold).double() + sine_waves = sine_waves * uv + return sine_waves.float(), uv.float(), torch.zeros_like(sine_waves, dtype=torch.float32) + + SineGen.forward = sinegen_forward_det + + # (3): canonical float64 source STFT + ConvSTFT inverse (export_paradee.py). + class RefSTFT(torch.nn.Module): + def __init__(self, n, hop): + super().__init__() + self.n, self.hop = n, hop + w = torch.hann_window(n, dtype=torch.float64) + k = torch.arange(n // 2 + 1, dtype=torch.float64)[:, None] + ang = 2 * math.pi * k * torch.arange(n, dtype=torch.float64) / n + self.re_b, self.im_b = torch.cos(ang) * w, -torch.sin(ang) * w + self.w64 = w + # inverse: ConvSTFT.inverse, float32 like the exported graph + wf = torch.hann_window(n) + kf = torch.arange(n // 2 + 1).float()[:, None] + angf = 2 * torch.pi * kf * torch.arange(n).float() / n + c = torch.full((n // 2 + 1, 1), 2.0); c[0] = c[-1] = 1 + self.inv = (torch.cat([torch.cos(angf) * c, -torch.sin(angf) * c]) / n * wf)[:, None] + self.w2 = (wf ** 2)[None, None] + + def transform(self, x): + xd = F.pad(x.double()[:, None], (self.n // 2, self.n // 2), mode="reflect")[:, 0] + frames = xd.unfold(-1, self.n, self.hop) # [B, T, n] + re = frames @ self.re_b.T # [B, T, K] + im = frames @ self.im_b.T + floor = 1e-12 * (frames * self.w64).abs().sum(-1, keepdim=True) + re = torch.where(re.abs() <= floor, torch.zeros_like(re), re) + im = torch.where(im.abs() <= floor, torch.zeros_like(im), im) + mag = torch.sqrt(re ** 2 + im ** 2) + ph = torch.where((im == 0) & (re < 0), torch.full_like(re, math.pi), torch.atan2(im, re)) + return mag.transpose(1, 2).float(), ph.transpose(1, 2).float() + + def inverse(self, mag, phase): + z = torch.cat([mag * torch.cos(phase), mag * torch.sin(phase)], 1) + y = F.conv_transpose1d(z, self.inv, stride=self.hop)[:, 0] + env = F.conv_transpose1d(torch.ones(1, 1, mag.shape[-1]), self.w2, stride=self.hop)[:, 0] + return (y / env.clamp_min(1e-8))[:, self.n // 2: -(self.n // 2)][:, None] + + ts = student.TextStudent("s+mlp").eval() + ts.load_state_dict(torch.load(model_dir / "pytorch/text_side.pt", map_location="cpu", weights_only=True)["model"]) + ds = student_decoder.load_student_decoder("A", str(model_dir / "pytorch/decoder.pt"), disable_complex=True) + strip(ts) + strip(ds) + g = ds.generator + g.stft = RefSTFT(g.stft.filter_length, g.stft.hop_length) + cap = {} + g.m_source.register_forward_hook(lambda m, i, o: cap.__setitem__("src", o[0])) + return torch, ts, ds, cap, phase_lock, student + + +def lock64(torch, phase_lock, s, src, f0, L): + """phase_lock.lock() evaluated in float64 (same operations; the module's + window and smoothing kernel are float32 constants, so it cannot take + float64 input as is).""" + import torch.nn.functional as F + n, hop = phase_lock.n, phase_lock.hop + W = torch.hann_window(n, dtype=torch.float64) + k = min(len(s), len(src)) + s, src = s[:k].double(), src[:k].double() + S = torch.stft(s, n, hop, window=W, return_complex=True) + E = torch.stft(src, n, hop, window=W, return_complex=True) + v = torch.tensor([float(f0[min(j * hop // 300, len(f0) - 1)]) > 60 for j in range(S.shape[1])])[None] + rel = S * E.conj() / E.abs().clamp_min(1e-6) + ker = torch.hann_window(L + 2, dtype=torch.float64)[1:-1] + ker = (ker / ker.sum())[None, None] + sm = torch.complex(F.conv1d(rel.real[:, None], ker, padding=L // 2)[:, 0], + F.conv1d(rel.imag[:, None], ker, padding=L // 2)[:, 0]) + ph = torch.where(phase_lock.BAND & v, E.angle() + sm.angle(), S.angle()) + return torch.istft(torch.polar(S.abs(), ph), n, hop, window=W, length=k) + + +def run_reference(torch, ts, ds, cap, phase_lock, student, ids: list[int], speed: float) -> dict: + """export_paradee.py Paradee.text() + forward(), with the stages kept.""" + import torch.nn as nn + x_ids = torch.tensor([ids], dtype=torch.long) + p, s = ts.predictor, ts.style + bert = ts.bert(x_ids, attention_mask=torch.ones_like(x_ids)) # [1,T,256] + d_en = ts.bert_encoder(bert).transpose(-1, -2) + T = x_ids.shape[1] + sx = s[:, :, None].expand(-1, -1, T) + x = torch.cat([d_en, sx], 1) + for b in p.text_encoder.lstms: + if isinstance(b, nn.LSTM): + x = b(x.transpose(-1, -2))[0].transpose(-1, -2) + else: + x = torch.cat([b(x.transpose(-1, -2), s).transpose(-1, -2), sx], 1) + d = x.transpose(-1, -2) # [1,T,224] + dur = torch.sigmoid(p.duration_proj(p.lstm(d)[0])).sum(-1)[0] # [T] + e = ts.text_encoder + h = e.embedding(x_ids).transpose(1, 2) + for c in e.cnn: + h = c(h) + t_en = e.lstm(h.transpose(1, 2))[0] + asr_tok = ts.asr_proj(t_en).transpose(-1, -2) # [1,512,T] + n = torch.clamp(torch.round(dur / speed), min=1).long() + aln = student.alignment(n)[None] # [1,T,F] + F0, N = p.F0Ntrain(d.transpose(-1, -2) @ aln, s) + asr = asr_tok @ aln # [1,512,F] + dec = ds(asr, F0, N).squeeze() # [L] + src = cap["src"].squeeze() + k = min(len(dec), len(src)) + lock_f32 = phase_lock.lock(dec, src, F0[0], 33) + wav = lock64(torch, phase_lock, dec, src, F0[0], 33).float() + f = lambda t: t.detach().contiguous().float().numpy() + return { + "ids": np.asarray(ids, dtype=np.int32), + "bert": f(bert[0]), "d": f(d[0]), "dur": f(dur / speed), "dur_int": n.numpy().astype(np.int32), + "F0": f(F0[0]), "N": f(N[0]), "asr": f(asr[0]), "dec": f(dec[:k]), "src": f(src[:k]), + "wav": f(wav), "lock_f32": f(lock_f32), + } + + +def cmd_dump(args) -> int: + fixture = json.loads(args.phonemes.read_text(encoding="utf-8")) + torch, ts, ds, cap, phase_lock, student = load_reference(args.model_dir, args.paradee_src) + args.out.mkdir(parents=True, exist_ok=True) + manifest = { + "model": PINS["model"], "source_commit": PINS["source"]["commit"], + "phonemes_fixture_sha256": sha256_file(args.phonemes), + "speed": args.speed, + "versions": {k: importlib.metadata.version(k) for k in ("torch", "kokoro", "misaki")}, + "reference": "deterministic: SineGen rand_ini=0, no noise, float64; canonical float64 source STFT; float64 phase lock", + "sentences": [], + } + for item in fixture["sentences"]: + out = run_reference(torch, ts, ds, cap, phase_lock, student, item["ids"], args.speed) + files = {} + for name, arr in out.items(): + ext = "i32" if arr.dtype == np.int32 else "f32" + fn = f"{item['index']}.{name}.{ext}" + arr.astype(" 220 Hz with an unvoiced gap; src is a tanh- + shaped harmonic excitation of that F0; s is a sum of its harmonics with a + slowly drifting phase offset, so the lock has something to correct.""" + n = LOCK_FIXTURE_SAMPLES + nf0 = n // 300 + f0 = np.array([0.0 if 12 <= j < 16 else 110.0 + 110.0 * j / (nf0 - 1) for j in range(nf0)]) + ph = 0.0 + s = np.zeros(n); src = np.zeros(n) + for t in range(n): + f = f0[t // 300] + ph += 2 * np.pi * f / SR + voiced = f > 0 + src[t] = np.tanh(0.8 * np.sin(ph)) * (1.0 if voiced else 0.0) + 0.01 * np.sin(0.37 * t) + acc = 0.0 + for h in range(1, 40): + if f * h >= 11000: + break + acc += np.sin(h * ph + 0.002 * h * t / 7.0 + 0.5 * h) / h + s[t] = 0.2 * acc * (1.0 if voiced else 0.3) + 0.003 * np.sin(1.3 * t) + return s.astype(np.float32), src.astype(np.float32), f0.astype(np.float32) + + +def cmd_lock_fixture(args) -> int: + for rel, digest in PINS["source"]["files"].items(): + check_file(args.paradee_src, rel, digest) + import torch + sys.path.insert(0, str(args.paradee_src / "training" / "scripts")) + phase_lock = importlib.import_module("phase_lock") + s, src, f0 = lock_fixture_inputs() + ts_, tsrc, tf0 = torch.from_numpy(s), torch.from_numpy(src), torch.from_numpy(f0) + with torch.no_grad(): + out64 = lock64(torch, phase_lock, ts_, tsrc, tf0, 33).float().numpy() + out32 = phase_lock.lock(ts_, tsrc, tf0, 33).numpy() + args.out.parent.mkdir(parents=True, exist_ok=True) + np.concatenate([out64, out32]).astype(" np.ndarray: + """HTK-scale triangular mel filters (torchaudio MelSpectrogram defaults: htk, no norm).""" + hz2mel = lambda f: 2595.0 * np.log10(1.0 + f / 700.0) + mel2hz = lambda m: 700.0 * (10.0 ** (m / 2595.0) - 1.0) + freqs = np.linspace(0, sr / 2, n_fft // 2 + 1) + pts = mel2hz(np.linspace(hz2mel(0.0), hz2mel(sr / 2), n_mels + 2)) + fb = np.zeros((n_fft // 2 + 1, n_mels)) + for m in range(n_mels): + lo, c, hi = pts[m], pts[m + 1], pts[m + 2] + up = (freqs - lo) / (c - lo) + down = (hi - freqs) / (hi - c) + fb[:, m] = np.maximum(0.0, np.minimum(up, down)) + return fb + + +def logmel(x: np.ndarray) -> np.ndarray: + """log(MelSpectrogram(24000, n_fft=1024, hop=256, n_mels=80)) as training/scripts/common.py.""" + n_fft, hop = 1024, 256 + x = x / max(np.sqrt(np.mean(x.astype(np.float64) ** 2)), 1e-8) + xp = np.pad(x.astype(np.float64), (n_fft // 2, n_fft // 2), mode="reflect") + w = 0.5 - 0.5 * np.cos(2 * np.pi * np.arange(n_fft) / n_fft) + frames = np.lib.stride_tricks.sliding_window_view(xp, n_fft)[::hop] * w + power = np.abs(np.fft.rfft(frames, axis=-1)) ** 2 + return np.log(np.maximum(power @ mel_filterbank(SR, n_fft, 80), 1e-5)).T # [80, T] + + +def dtw_l1(a: np.ndarray, b: np.ndarray) -> float: + a, b = a.T, b.T + C = np.abs(a[:, None, :] - b[None, :, :]).mean(-1) + n, m = C.shape + D = np.full((n + 1, m + 1), np.inf); D[0, 0] = 0 + for i in range(1, n + 1): + Di, Dp, Ci = D[i], D[i - 1], C[i - 1] + for j in range(1, m + 1): + Di[j] = Ci[j - 1] + min(Dp[j], Di[j - 1], Dp[j - 1]) + i, j, path = n, m, 0 + while i > 0 and j > 0: + path += 1 + k = int(np.argmin([D[i - 1, j - 1], D[i - 1, j], D[i, j - 1]])) + i, j = (i - 1, j - 1) if k == 0 else (i - 1, j) if k == 1 else (i, j - 1) + return float(D[n, m] / path) + + +def read_wav_f32(path: Path) -> np.ndarray: + import wave + with wave.open(str(path), "rb") as w: + assert w.getframerate() == SR and w.getnchannels() == 1 and w.getsampwidth() == 2, path + return np.frombuffer(w.readframes(w.getnframes()), dtype=" tuple[float, float]: + la, lb = logmel(a), logmel(b) + k = min(la.shape[1], lb.shape[1]) + return float(np.abs(la[:, :k] - lb[:, :k]).mean()), dtw_l1(la, lb) + + +def write_wav16k(path: Path, x: np.ndarray) -> None: + """24 kHz float -> 16 kHz 16-bit WAV (band-limited FFT resampling) for whisper.cpp.""" + import wave + n = len(x); m = int(round(n * 16000 / SR)) + X = np.fft.rfft(x.astype(np.float64)) + Y = np.zeros(m // 2 + 1, complex); k = min(len(X), len(Y)); Y[:k] = X[:k] + y = np.clip(np.fft.irfft(Y, m) * (m / n), -1.0, 1.0) + with wave.open(str(path), "wb") as w: + w.setnchannels(1); w.setsampwidth(2); w.setframerate(16000) + w.writeframes((y * 32767).astype(" list[str]: + """Lower-case words with digits spelled out (years as years), punctuation and 'and' dropped, + so '2015' and 'twenty fifteen' compare equal.""" + import re + from num2words import num2words + t = text.lower().replace("-", " ") + t = re.sub(r"(\d+)'s", r"\1s", t) + t = re.sub(r"\d+", lambda m: num2words(int(m.group(0)), to="year") if 1100 <= int(m.group(0)) <= 2099 + else num2words(int(m.group(0))), t) + t = re.sub(r"[^a-z' ]", " ", t.replace("-", " ")) + return [w for w in t.split() if w != "and"] + + +def edit_distance(ref: list[str], hyp: list[str]) -> int: + d = list(range(len(hyp) + 1)) + for i, r in enumerate(ref, 1): + prev, d[0] = d[0], i + for j, h in enumerate(hyp, 1): + prev, d[j] = d[j], min(d[j] + 1, d[j - 1] + 1, prev + (r != h)) + return d[len(hyp)] + + +def report_wer(args, manifest, texts) -> None: + import subprocess + import tempfile + totals = {"native": [0, 0], "published": [0, 0]} + with tempfile.TemporaryDirectory() as tmp: + for s in manifest["sentences"]: + i = s["index"] + audio = {"native": np.fromfile(args.native / f"{i}.wav.f32", dtype=" int: + manifest = json.loads((args.ref / "manifest.json").read_text(encoding="utf-8")) + rows = [] + for s in manifest["sentences"]: + i = s["index"] + ref = np.fromfile(args.ref / s["files"]["wav"]["file"], dtype="2} {'native s':>8} {'pub s':>6} | {'nat-ref raw':>11} {'dtw':>6} | {'nat-pub raw':>11} {'dtw':>6} | {'ref-pub raw':>11} {'dtw':>6}") + for r in rows: + print(f"{r[0]:>2} {r[1]:8.2f} {r[2]:6.2f} | {r[3]:11.4f} {r[4]:6.4f} | {r[5]:11.4f} {r[6]:6.4f} | {r[7]:11.4f} {r[8]:6.4f}") + m = np.mean(np.array([r[3:] for r in rows]), axis=0) + print(f"mean | {m[0]:11.4f} {m[1]:6.4f} | {m[2]:11.4f} {m[3]:6.4f} | {m[4]:11.4f} {m[5]:6.4f}") + if args.whisper_cli: + fixture = json.loads(args.phonemes.read_text(encoding="utf-8")) + texts = {s["index"]: s["text"] for s in fixture["sentences"]} + print(f"\nWER with whisper.cpp ({args.whisper_model.name}), native vs the published samples:") + report_wer(args, manifest, texts) + return 0 + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = ap.add_subparsers(dest="cmd", required=True) + p = sub.add_parser("phonemes") + p.add_argument("--model-dir", type=Path, required=True) + p.add_argument("--out", type=Path, default=DEFAULT_FIXTURE) + p.add_argument("--espeak-lib", help="libespeak-ng to use instead of espeakng_loader's") + p.add_argument("--espeak-data", help="espeak-ng-data directory to use instead of espeakng_loader's") + p = sub.add_parser("dump") + p.add_argument("--model-dir", type=Path, required=True) + p.add_argument("--paradee-src", type=Path, required=True) + p.add_argument("--phonemes", type=Path, default=DEFAULT_FIXTURE) + p.add_argument("--speed", type=float, default=1.0) + p.add_argument("--out", type=Path, required=True) + p = sub.add_parser("lock-fixture") + p.add_argument("--paradee-src", type=Path, required=True) + p.add_argument("--out", type=Path, default=PARADEE_DIR / "tests" / "fixtures" / "phase_lock_synthetic.f32") + p = sub.add_parser("report") + p.add_argument("--model-dir", type=Path, required=True) + p.add_argument("--ref", type=Path, required=True) + p.add_argument("--native", type=Path, required=True) + p.add_argument("--phonemes", type=Path, default=DEFAULT_FIXTURE) + p.add_argument("--whisper-cli", type=Path, help="whisper.cpp whisper-cli binary: also report WER") + p.add_argument("--whisper-model", type=Path, help="whisper.cpp ggml model (e.g. ggml-base.en.bin)") + args = ap.parse_args(argv) + return {"phonemes": cmd_phonemes, "dump": cmd_dump, "lock-fixture": cmd_lock_fixture, + "report": cmd_report}[args.cmd](args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/paradee/tools/paradee-stage-dump.cpp b/tools/paradee/tools/paradee-stage-dump.cpp new file mode 100644 index 000000000..fd453c908 --- /dev/null +++ b/tools/paradee/tools/paradee-stage-dump.cpp @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: MIT +// +// paradee-stage-dump — run one synthesis and write every stage as raw +// little-endian arrays, in the layouts tools/dump_paradee_reference.py writes +// for the PyTorch reference: +// +// paradee-stage-dump [speed] +// paradee-stage-dump --decode +// +// --decode runs only the decoder, generator and phase lock on given text-side +// outputs (asr [512, T] channel-major, F0 / N [2T]). +// +// writes .{bert,d,dur,F0,N,asr,dec,src,wav}.f32, .dur_int.i32 +// and .ids.i32. + +#include "paradee.h" + +#include +#include +#include +#include +#include +#include + +template +static bool write_raw(const std::string & path, const std::vector & v) { + std::ofstream f(path, std::ios::binary); + f.write((const char *) v.data(), (std::streamsize) (v.size() * sizeof(T))); + return (bool) f; +} + +static std::vector read_f32(const char * path) { + std::ifstream f(path, std::ios::binary | std::ios::ate); + std::vector v((size_t) f.tellg() / sizeof(float)); + f.seekg(0); + f.read((char *) v.data(), (std::streamsize) (v.size() * sizeof(float))); + return v; +} + +int main(int argc, char ** argv) { + if (argc == 7 && std::string(argv[1]) == "--decode") { + std::string err; + auto model = eliza_paradee::paradee_load_model(argv[2], err); + if (!model) { std::fprintf(stderr, "load: %s\n", err.c_str()); return 1; } + const std::vector asr = read_f32(argv[3]), F0 = read_f32(argv[4]), N = read_f32(argv[5]); + const int T = (int) F0.size() / 2; + eliza_paradee::paradee_stages st; + if (eliza_paradee::paradee_decode_staged(model.get(), asr.data(), (int) (asr.size() / (size_t) T), T, + F0.data(), N.data(), nullptr, nullptr, nullptr, &st, err) != eliza_paradee::PARADEE_OK) { + std::fprintf(stderr, "decode: %s\n", err.c_str()); + return 1; + } + const std::string pre = argv[6]; + return write_raw(pre + ".dec.f32", st.dec) && write_raw(pre + ".src.f32", st.src) && + write_raw(pre + ".wav.f32", st.wav) ? 0 : 1; + } + if (argc < 4) { + std::fprintf(stderr, "usage: %s [speed]\n", argv[0]); + return 2; + } + std::ifstream in(argv[2], std::ios::binary); + std::stringstream ss; ss << in.rdbuf(); + std::string ph = ss.str(); + while (!ph.empty() && (ph.back() == '\n' || ph.back() == '\r')) ph.pop_back(); + const float speed = argc > 4 ? std::strtof(argv[4], nullptr) : 1.0f; + const std::string pre = argv[3]; + + std::string err; + auto model = eliza_paradee::paradee_load_model(argv[1], err); + if (!model) { std::fprintf(stderr, "load: %s\n", err.c_str()); return 1; } + std::vector ids; + if (eliza_paradee::paradee_phonemes_to_ids(model.get(), ph, ids, err) != eliza_paradee::PARADEE_OK) { + std::fprintf(stderr, "phonemes: %s\n", err.c_str()); + return 1; + } + eliza_paradee::paradee_stages st; + std::vector pcm; + if (eliza_paradee::paradee_synthesize_ids_staged(model.get(), ids.data(), ids.size(), speed, + nullptr, nullptr, pcm, err, &st) != eliza_paradee::PARADEE_OK) { + std::fprintf(stderr, "synthesis: %s\n", err.c_str()); + return 1; + } + bool ok = write_raw(pre + ".ids.i32", ids) && write_raw(pre + ".bert.f32", st.bert) && + write_raw(pre + ".d.f32", st.d) && write_raw(pre + ".dur.f32", st.dur) && + write_raw(pre + ".dur_int.i32", st.dur_int) && write_raw(pre + ".F0.f32", st.F0) && + write_raw(pre + ".N.f32", st.N) && write_raw(pre + ".asr.f32", st.asr) && + write_raw(pre + ".dec.f32", st.dec) && write_raw(pre + ".src.f32", st.src) && + write_raw(pre + ".wav.f32", st.wav); + std::printf("T_phon=%d T_frame=%d samples=%zu\n", st.T_phon, st.T_frame, st.wav.size()); + return ok ? 0 : 1; +} diff --git a/tools/paradee/tools/paradee-tts.cpp b/tools/paradee/tools/paradee-tts.cpp new file mode 100644 index 000000000..8c69617de --- /dev/null +++ b/tools/paradee/tools/paradee-tts.cpp @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: MIT +// +// paradee-tts — synthesize misaki phonemes with Paradee-8M to a 24 kHz WAV. +// +// paradee-tts --model paradee-8m-v1.0-f32.gguf --phonemes "həlˈO wˈɜɹld." --output hello.wav +// [--speed 1.0] [--f32] [--repeat N] +// +// The input is misaki's phoneme spelling (Kokoro's G2P), not text and not +// eSpeak IPA. --repeat N synthesizes N times and reports the real-time factor +// of the fastest run (thread count: KOKORO_NUM_THREADS; on Apple also +// VECLIB_MAXIMUM_THREADS). + +#include "paradee.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +void usage(const char * argv0) { + std::fprintf(stderr, + "usage: %s --model (--phonemes \"...\" | --phonemes-file ) --output \n" + " [--speed 1.0] [--f32] [--repeat N]\n", argv0); +} + +bool write_wav(const std::string & path, const std::vector & samples, int sample_rate, bool f32) { + std::ofstream f(path, std::ios::binary); + if (!f) return false; + const uint32_t n = (uint32_t) samples.size(); + const uint16_t bits = f32 ? 32 : 16, fmt = f32 ? 3 : 1; + const uint32_t bytes = n * (bits / 8u); + auto p32 = [&f](uint32_t v) { char b[4] = { (char) v, (char) (v >> 8), (char) (v >> 16), (char) (v >> 24) }; f.write(b, 4); }; + auto p16 = [&f](uint16_t v) { char b[2] = { (char) v, (char) (v >> 8) }; f.write(b, 2); }; + f.write("RIFF", 4); p32(36 + bytes); f.write("WAVE", 4); + f.write("fmt ", 4); p32(16); p16(fmt); p16(1); p32((uint32_t) sample_rate); + p32((uint32_t) sample_rate * (bits / 8u)); p16(bits / 8); p16(bits); + f.write("data", 4); p32(bytes); + for (uint32_t i = 0; i < n; ++i) { + if (f32) { + f.write((const char *) &samples[i], 4); + } else { + const float v = std::max(-1.0f, std::min(1.0f, samples[i])); + const int16_t s = (int16_t) std::lrintf(v * 32767.0f); + p16((uint16_t) s); + } + } + return (bool) f; +} + +} // namespace + +int main(int argc, char ** argv) { + std::string model_path, phonemes, out_path; + float speed = 1.0f; + bool f32 = false; + int repeat = 1; + for (int i = 1; i < argc; ++i) { + const std::string a = argv[i]; + auto next = [&]() -> const char * { return i + 1 < argc ? argv[++i] : nullptr; }; + const char * v = nullptr; + if (a == "--model" && (v = next())) model_path = v; + else if (a == "--phonemes" && (v = next())) phonemes = v; + else if (a == "--phonemes-file" && (v = next())) { + std::ifstream in(v, std::ios::binary); + std::stringstream ss; ss << in.rdbuf(); phonemes = ss.str(); + while (!phonemes.empty() && (phonemes.back() == '\n' || phonemes.back() == '\r')) phonemes.pop_back(); + } + else if (a == "--output" && (v = next())) out_path = v; + else if (a == "--speed" && (v = next())) speed = std::strtof(v, nullptr); + else if (a == "--repeat" && (v = next())) repeat = std::max(1, std::atoi(v)); + else if (a == "--f32") f32 = true; + else { usage(argv[0]); return 2; } + } + if (model_path.empty() || phonemes.empty() || out_path.empty()) { usage(argv[0]); return 2; } + + std::string err; + auto t0 = std::chrono::steady_clock::now(); + auto model = eliza_paradee::paradee_load_model(model_path, err); + if (!model) { std::fprintf(stderr, "load failed: %s\n", err.c_str()); return 1; } + const double load_ms = std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); + + std::vector ids; + eliza_paradee::paradee_status st = eliza_paradee::paradee_phonemes_to_ids(model.get(), phonemes, ids, err); + if (st != eliza_paradee::PARADEE_OK) { + std::fprintf(stderr, "phonemes rejected (%s): %s\n", eliza_paradee::paradee_status_str(st), err.c_str()); + return 1; + } + std::vector pcm; + double best = 1e30; + for (int r = 0; r < repeat; ++r) { + const auto t = std::chrono::steady_clock::now(); + st = eliza_paradee::paradee_synthesize_ids(model.get(), ids.data(), ids.size(), speed, nullptr, nullptr, pcm, err); + best = std::min(best, std::chrono::duration(std::chrono::steady_clock::now() - t).count()); + if (st != eliza_paradee::PARADEE_OK) { + std::fprintf(stderr, "synthesis failed (%s): %s\n", eliza_paradee::paradee_status_str(st), err.c_str()); + return 1; + } + } + const int sr = eliza_paradee::paradee_sample_rate(model.get()); + if (!write_wav(out_path, pcm, sr, f32)) { std::fprintf(stderr, "cannot write %s\n", out_path.c_str()); return 1; } + const double secs = (double) pcm.size() / sr; + std::printf("ids=%zu samples=%zu audio=%.2fs load=%.0fms synth=%.3fs rtf=%.4f (%.1fx realtime)\n", + ids.size(), pcm.size(), secs, load_ms, best, best / secs, secs / best); + return 0; +}