Some checks failed
Linux Build / Linux cpu (push) Has been cancelled
Linux Build / Linux cpu-clang (push) Has been cancelled
Linux Build / Linux vulkan (push) Has been cancelled
mac-build / macOS CPU (push) Has been cancelled
Nix Build / Nix (cpu) (push) Has been cancelled
Nix Build / Nix (vulkan) (push) Has been cancelled
windows-build / Windows CPU (push) Has been cancelled
402 lines
15 KiB
Python
402 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
from collections import OrderedDict
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import torch
|
|
from safetensors import safe_open
|
|
from safetensors.torch import load_file, save_file
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="Convert Confucius4-TTS reference checkpoints to an AudioCPP safetensors bundle."
|
|
)
|
|
parser.add_argument("--model-root", default="models/Confucius4-TTS")
|
|
parser.add_argument("--w2v-root", default="models/facebook-w2v-bert-2.0")
|
|
parser.add_argument("--bigvgan-root", default="models/bigvgan_v2_22khz_80band_256x")
|
|
parser.add_argument("--campplus", default="models/funasr-campplus/campplus_cn_common.bin")
|
|
parser.add_argument("--reference-root", default="reference/Confucius4-TTS")
|
|
parser.add_argument("--output-dir", default="models/Confucius4-TTS/audio_cpp")
|
|
parser.add_argument("--overwrite", action="store_true")
|
|
parser.add_argument("--skip-semantic-shaw", action="store_true")
|
|
parser.add_argument("--no-verify", action="store_true")
|
|
parser.add_argument("--write-gguf", action="store_true")
|
|
parser.add_argument("--gguf-output", default="model.gguf")
|
|
parser.add_argument("--gguf-type", default="orig")
|
|
parser.add_argument("--gguf-tool", default="build/debug/bin/audiocpp_gguf")
|
|
return parser.parse_args()
|
|
|
|
|
|
def resolve_path(path: str | Path) -> Path:
|
|
candidate = Path(path).expanduser()
|
|
if candidate.is_absolute():
|
|
return candidate
|
|
return REPO_ROOT / candidate
|
|
|
|
|
|
def require_file(path: Path) -> Path:
|
|
if not path.is_file():
|
|
raise RuntimeError(f"required file does not exist: {path}")
|
|
return path
|
|
|
|
|
|
def require_dir(path: Path) -> Path:
|
|
if not path.is_dir():
|
|
raise RuntimeError(f"required directory does not exist: {path}")
|
|
return path
|
|
|
|
|
|
def tensor_bytes(tensor: torch.Tensor) -> int:
|
|
return tensor.numel() * tensor.element_size()
|
|
|
|
|
|
def prepare_tensor(value: torch.Tensor) -> torch.Tensor:
|
|
return value.detach().cpu().contiguous()
|
|
|
|
|
|
def as_tensor_dict(payload: Any, label: str) -> OrderedDict[str, torch.Tensor]:
|
|
if not isinstance(payload, dict):
|
|
raise RuntimeError(f"{label} must be a dict-like state payload")
|
|
tensors: OrderedDict[str, torch.Tensor] = OrderedDict()
|
|
for key in sorted(payload):
|
|
value = payload[key]
|
|
if not isinstance(key, str):
|
|
raise RuntimeError(f"{label} contains a non-string key: {key!r}")
|
|
if not torch.is_tensor(value):
|
|
continue
|
|
tensors[key] = prepare_tensor(value)
|
|
if not tensors:
|
|
raise RuntimeError(f"{label} did not contain tensors")
|
|
return tensors
|
|
|
|
|
|
def checkpoint_tensors(path: Path, label: str) -> OrderedDict[str, torch.Tensor]:
|
|
payload = torch.load(path, map_location="cpu", weights_only=False)
|
|
if isinstance(payload, dict):
|
|
for wrapper_key in ("state_dict", "generator", "model"):
|
|
wrapped = payload.get(wrapper_key)
|
|
if isinstance(wrapped, dict):
|
|
return as_tensor_dict(wrapped, f"{label}.{wrapper_key}")
|
|
return as_tensor_dict(payload, label)
|
|
|
|
|
|
def prefixed_tensors(tensors: OrderedDict[str, torch.Tensor], prefix: str) -> OrderedDict[str, torch.Tensor]:
|
|
if not prefix:
|
|
raise RuntimeError("prefixed_tensors requires a non-empty prefix")
|
|
return OrderedDict((f"{prefix}.{key}", value) for key, value in tensors.items())
|
|
|
|
|
|
def effective_weight_norm(v: torch.Tensor, g: torch.Tensor) -> torch.Tensor:
|
|
if v.ndim < 2 or g.shape[0] != v.shape[0]:
|
|
raise RuntimeError(f"unsupported weight_norm tensor shapes: v={tuple(v.shape)} g={tuple(g.shape)}")
|
|
dims = tuple(range(1, v.ndim))
|
|
norm = torch.linalg.vector_norm(v.float(), ord=2, dim=dims, keepdim=True)
|
|
if torch.any(norm == 0):
|
|
raise RuntimeError("weight_norm source contains a zero norm row")
|
|
g_view = g.float().reshape((g.shape[0],) + (1,) * (v.ndim - 1))
|
|
return prepare_tensor((v.float() * (g_view / norm)).to(v.dtype))
|
|
|
|
|
|
def materialize_weight_norm(state: OrderedDict[str, torch.Tensor]) -> OrderedDict[str, torch.Tensor]:
|
|
out: OrderedDict[str, torch.Tensor] = OrderedDict()
|
|
consumed: set[str] = set()
|
|
for key, value in state.items():
|
|
if key in consumed:
|
|
continue
|
|
if key.endswith(".parametrizations.weight.original1"):
|
|
prefix = key[: -len(".parametrizations.weight.original1")]
|
|
g_key = prefix + ".parametrizations.weight.original0"
|
|
if g_key in state:
|
|
out[prefix + ".weight"] = effective_weight_norm(value, state[g_key])
|
|
consumed.add(key)
|
|
consumed.add(g_key)
|
|
continue
|
|
if key.endswith(".weight_v"):
|
|
prefix = key[: -len(".weight_v")]
|
|
g_key = prefix + ".weight_g"
|
|
if g_key in state:
|
|
out[prefix + ".weight"] = effective_weight_norm(value, state[g_key])
|
|
consumed.add(key)
|
|
consumed.add(g_key)
|
|
continue
|
|
if key.endswith(".parametrizations.weight.original0") or key.endswith(".weight_g"):
|
|
continue
|
|
out[key] = value
|
|
if not out:
|
|
raise RuntimeError("weight_norm materialization produced an empty state")
|
|
return out
|
|
|
|
|
|
def safetensors_info(path: Path) -> dict[str, Any]:
|
|
tensor_count = 0
|
|
total_tensor_bytes = 0
|
|
dtypes: dict[str, int] = {}
|
|
with safe_open(str(path), framework="pt", device="cpu") as handle:
|
|
metadata = dict(handle.metadata() or {})
|
|
for key in handle.keys():
|
|
tensor = handle.get_tensor(key)
|
|
tensor_count += 1
|
|
total_tensor_bytes += tensor_bytes(tensor)
|
|
dtype_name = str(tensor.dtype).replace("torch.", "")
|
|
dtypes[dtype_name] = dtypes.get(dtype_name, 0) + 1
|
|
return {
|
|
"file": path.name,
|
|
"bytes": path.stat().st_size,
|
|
"tensor_bytes": total_tensor_bytes,
|
|
"tensor_count": tensor_count,
|
|
"dtypes": dtypes,
|
|
"metadata": metadata,
|
|
}
|
|
|
|
|
|
def verify_saved(path: Path, expected: OrderedDict[str, torch.Tensor]) -> None:
|
|
actual = load_file(str(path), device="cpu")
|
|
if set(actual.keys()) != set(expected.keys()):
|
|
raise RuntimeError(f"saved safetensors key set changed: {path}")
|
|
for key, expected_tensor in expected.items():
|
|
actual_tensor = actual[key]
|
|
if actual_tensor.shape != expected_tensor.shape:
|
|
raise RuntimeError(
|
|
f"saved tensor shape changed for {key}: {tuple(expected_tensor.shape)} -> {tuple(actual_tensor.shape)}"
|
|
)
|
|
if actual_tensor.dtype != expected_tensor.dtype:
|
|
raise RuntimeError(f"saved tensor dtype changed for {key}: {expected_tensor.dtype} -> {actual_tensor.dtype}")
|
|
if not torch.equal(actual_tensor, expected_tensor):
|
|
raise RuntimeError(f"saved tensor values changed for {key}")
|
|
|
|
|
|
def write_safetensors(
|
|
tensors: OrderedDict[str, torch.Tensor],
|
|
output_path: Path,
|
|
metadata: dict[str, str],
|
|
overwrite: bool,
|
|
verify: bool,
|
|
) -> dict[str, Any]:
|
|
if output_path.exists() and not overwrite:
|
|
raise RuntimeError(f"output file already exists: {output_path}")
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
save_file(dict(tensors), str(output_path), metadata=metadata)
|
|
if verify:
|
|
verify_saved(output_path, tensors)
|
|
info = safetensors_info(output_path)
|
|
info["source_format"] = metadata.get("source_format", "")
|
|
info["source_file"] = metadata.get("source_file", "")
|
|
return info
|
|
|
|
|
|
def copy_safetensors(source_path: Path, output_path: Path, metadata_label: str, overwrite: bool) -> dict[str, Any]:
|
|
if output_path.exists() and not overwrite:
|
|
raise RuntimeError(f"output file already exists: {output_path}")
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(source_path, output_path)
|
|
info = safetensors_info(output_path)
|
|
info["source_format"] = "safetensors"
|
|
info["source_file"] = str(source_path)
|
|
info["component"] = metadata_label
|
|
return info
|
|
|
|
|
|
def copy_required_asset(source_path: Path, output_path: Path, overwrite: bool) -> dict[str, Any]:
|
|
if output_path.exists() and not overwrite:
|
|
raise RuntimeError(f"output file already exists: {output_path}")
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(source_path, output_path)
|
|
return {"file": output_path.name, "bytes": output_path.stat().st_size, "source_file": str(source_path)}
|
|
|
|
|
|
def wav2vec_stats_tensors(path: Path) -> OrderedDict[str, torch.Tensor]:
|
|
payload = torch.load(path, map_location="cpu", weights_only=False)
|
|
if not isinstance(payload, dict):
|
|
raise RuntimeError(f"wav2vec2bert stats must be a dict: {path}")
|
|
tensors: OrderedDict[str, torch.Tensor] = OrderedDict()
|
|
for key in ("mean", "var"):
|
|
value = payload.get(key)
|
|
if not torch.is_tensor(value):
|
|
raise RuntimeError(f"wav2vec2bert stats missing tensor: {key}")
|
|
tensors[key] = prepare_tensor(value)
|
|
return tensors
|
|
|
|
|
|
def write_json(path: Path, payload: dict[str, Any], overwrite: bool) -> None:
|
|
if path.exists() and not overwrite:
|
|
raise RuntimeError(f"output file already exists: {path}")
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
|
|
|
|
def write_gguf_bundle(args: argparse.Namespace, output_dir: Path) -> dict[str, Any]:
|
|
gguf_tool = require_file(resolve_path(args.gguf_tool))
|
|
gguf_output = Path(args.gguf_output)
|
|
if not gguf_output.is_absolute():
|
|
gguf_output = output_dir / gguf_output
|
|
command = [
|
|
str(gguf_tool),
|
|
"--input",
|
|
f"t2s={output_dir / 't2s.safetensors'}",
|
|
"--input",
|
|
f"s2a={output_dir / 's2a.safetensors'}",
|
|
"--input",
|
|
f"semantic_encoder={output_dir / 'semantic_encoder.safetensors'}",
|
|
"--input",
|
|
f"semantic_encoder_shaw={output_dir / 'semantic_encoder_shaw.safetensors'}",
|
|
"--input",
|
|
f"semantic_stats={output_dir / 'semantic_stats.safetensors'}",
|
|
"--input",
|
|
f"style_encoder={output_dir / 'style_encoder.safetensors'}",
|
|
"--input",
|
|
f"vocoder={output_dir / 'vocoder.safetensors'}",
|
|
"--output",
|
|
str(gguf_output),
|
|
"--type",
|
|
args.gguf_type,
|
|
"--family",
|
|
"confucius4_tts",
|
|
"--model-spec",
|
|
str(REPO_ROOT / "model_specs" / "confucius4_tts.json"),
|
|
"--root",
|
|
str(output_dir),
|
|
]
|
|
if args.overwrite:
|
|
command.append("--overwrite")
|
|
subprocess.run(command, cwd=REPO_ROOT, check=True)
|
|
return {
|
|
"file": gguf_output.name,
|
|
"bytes": gguf_output.stat().st_size,
|
|
"type": args.gguf_type,
|
|
}
|
|
|
|
|
|
def main() -> None:
|
|
args = parse_args()
|
|
model_root = require_dir(resolve_path(args.model_root))
|
|
w2v_root = require_dir(resolve_path(args.w2v_root))
|
|
bigvgan_root = require_dir(resolve_path(args.bigvgan_root))
|
|
reference_root = require_dir(resolve_path(args.reference_root))
|
|
campplus_path = require_file(resolve_path(args.campplus))
|
|
output_dir = resolve_path(args.output_dir)
|
|
verify = not args.no_verify
|
|
|
|
components: dict[str, Any] = {}
|
|
assets: dict[str, Any] = {}
|
|
|
|
components["t2s"] = copy_safetensors(
|
|
require_file(model_root / "t2s_model.safetensors"),
|
|
output_dir / "t2s.safetensors",
|
|
"t2s",
|
|
args.overwrite,
|
|
)
|
|
components["semantic_encoder"] = copy_safetensors(
|
|
require_file(w2v_root / "model.safetensors"),
|
|
output_dir / "semantic_encoder.safetensors",
|
|
"semantic_encoder",
|
|
args.overwrite,
|
|
)
|
|
if not args.skip_semantic_shaw:
|
|
components["semantic_encoder_shaw"] = write_safetensors(
|
|
checkpoint_tensors(require_file(w2v_root / "conformer_shaw.pt"), "semantic_encoder_shaw"),
|
|
output_dir / "semantic_encoder_shaw.safetensors",
|
|
{
|
|
"format": "pt",
|
|
"audio_cpp_model": "confucius4_tts",
|
|
"component": "semantic_encoder_shaw",
|
|
"source_format": "pytorch",
|
|
"source_file": str(w2v_root / "conformer_shaw.pt"),
|
|
},
|
|
args.overwrite,
|
|
verify,
|
|
)
|
|
|
|
components["semantic_stats"] = write_safetensors(
|
|
wav2vec_stats_tensors(require_file(model_root / "wav2vec2bert_stats.pt")),
|
|
output_dir / "semantic_stats.safetensors",
|
|
{
|
|
"format": "pt",
|
|
"audio_cpp_model": "confucius4_tts",
|
|
"component": "semantic_stats",
|
|
"source_format": "pytorch",
|
|
"source_file": str(model_root / "wav2vec2bert_stats.pt"),
|
|
},
|
|
args.overwrite,
|
|
verify,
|
|
)
|
|
components["s2a"] = write_safetensors(
|
|
checkpoint_tensors(require_file(model_root / "s2a_model.pt"), "s2a"),
|
|
output_dir / "s2a.safetensors",
|
|
{
|
|
"format": "pt",
|
|
"audio_cpp_model": "confucius4_tts",
|
|
"component": "s2a",
|
|
"source_format": "pytorch",
|
|
"source_file": str(model_root / "s2a_model.pt"),
|
|
},
|
|
args.overwrite,
|
|
verify,
|
|
)
|
|
components["style_encoder"] = write_safetensors(
|
|
prefixed_tensors(checkpoint_tensors(campplus_path, "style_encoder"), "speaker_encoder"),
|
|
output_dir / "style_encoder.safetensors",
|
|
{
|
|
"format": "pt",
|
|
"audio_cpp_model": "confucius4_tts",
|
|
"component": "style_encoder",
|
|
"source_format": "pytorch",
|
|
"source_file": str(campplus_path),
|
|
},
|
|
args.overwrite,
|
|
verify,
|
|
)
|
|
components["vocoder"] = write_safetensors(
|
|
checkpoint_tensors(require_file(bigvgan_root / "bigvgan_generator.pt"), "vocoder"),
|
|
output_dir / "vocoder.safetensors",
|
|
{
|
|
"format": "pt",
|
|
"audio_cpp_model": "confucius4_tts",
|
|
"component": "vocoder",
|
|
"source_format": "pytorch",
|
|
"source_file": str(bigvgan_root / "bigvgan_generator.pt"),
|
|
},
|
|
args.overwrite,
|
|
verify,
|
|
)
|
|
|
|
for name in ("tokenizer.model", "tokenizer.json", "tokenizer_config.json", "special_tokens_map.json"):
|
|
assets[name] = copy_required_asset(require_file(model_root / name), output_dir / name, args.overwrite)
|
|
assets["w2v_preprocessor_config.json"] = copy_required_asset(
|
|
require_file(w2v_root / "preprocessor_config.json"),
|
|
output_dir / "w2v_preprocessor_config.json",
|
|
args.overwrite,
|
|
)
|
|
assets["bigvgan_config.json"] = copy_required_asset(
|
|
require_file(bigvgan_root / "config.json"),
|
|
output_dir / "bigvgan_config.json",
|
|
args.overwrite,
|
|
)
|
|
assets["inference_config.yaml"] = copy_required_asset(
|
|
require_file(reference_root / "config" / "inference_config.yaml"),
|
|
output_dir / "inference_config.yaml",
|
|
args.overwrite,
|
|
)
|
|
manifest = {
|
|
"format": "audio_cpp_confucius4_tts_safetensors_bundle",
|
|
"version": 1,
|
|
"components": components,
|
|
"assets": assets,
|
|
}
|
|
if args.write_gguf:
|
|
manifest["gguf"] = write_gguf_bundle(args, output_dir)
|
|
write_json(output_dir / "manifest.json", manifest, args.overwrite)
|
|
print(json.dumps(manifest, indent=2, sort_keys=True))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|