Files
le-mystere-professeur-zacus/tools/tts/generate_xtts_pool.py
T
L'électron rare 9f4501b9b9 feat: Zacus V3 complete
Phase 1: zacus_v3_complete.yaml, constants, phrases
Phase 3: ESP-NOW framework, 4 puzzle firmwares, master
Phase 4: NPC V3 profiling + game coordinator
Phase 5: XTTS-v2 Docker + voice clone generator
Phase 6: AudioCraft + BLE audio control
Phase 9: One-pager, video script, pricing, guide
2026-04-03 00:42:34 +02:00

198 lines
6.3 KiB
Python

#!/usr/bin/env python3
"""
generate_xtts_pool.py — Pre-generate all NPC phrases via XTTS-v2 (GPU voice clone).
Reads game/scenarios/npc_phrases.yaml, calls XTTS-v2 on kxkm-ai, saves WAV files
to hotline_tts/ directory, updates hotline_tts/manifest.json.
Idempotent: skips already-generated files unless --force is passed.
Usage:
python3 tools/tts/generate_xtts_pool.py [--dry-run] [--force] [--host kxkm-ai:5002]
Requirements:
pip install pyyaml requests
"""
import argparse
import json
import pathlib
import sys
import time
from typing import Any
import requests
import yaml
# Paths relative to project root (run from repo root)
PHRASES_YAML = pathlib.Path("game/scenarios/npc_phrases.yaml")
OUTPUT_DIR = pathlib.Path("hotline_tts")
MANIFEST = OUTPUT_DIR / "manifest.json"
DEFAULT_HOST = "kxkm-ai:5002"
# HTTP request settings
REQUEST_TIMEOUT_S = 30
RETRY_COUNT = 2
RETRY_DELAY_S = 3
def flatten_phrases(data: dict[str, Any], prefix: str = "") -> list[dict[str, str]]:
"""Recursively extract all phrase entries with their keys and text.
Handles both list-of-dicts (with 'text' + 'key') and nested dicts.
Returns list of {"key": str, "text": str} dicts.
"""
phrases: list[dict[str, str]] = []
for k, v in data.items():
path = f"{prefix}.{k}" if prefix else k
if isinstance(v, list):
for i, item in enumerate(v):
if isinstance(item, dict) and "text" in item:
phrases.append({
"key": item.get("key", f"{path}.{i}"),
"text": item["text"],
})
elif isinstance(v, dict):
phrases.extend(flatten_phrases(v, path))
return phrases
def key_to_path(key: str) -> pathlib.Path:
"""Convert phrase key to output file path.
Example: hints.P1_SON.level_1.0 → hotline_tts/hints/P1_SON/level_1/0.wav
"""
parts = key.replace(".", "/")
return OUTPUT_DIR / f"{parts}.wav"
def synthesize(text: str, host: str) -> bytes:
"""Call XTTS-v2 /v1/audio/speech endpoint, return raw WAV bytes.
Raises requests.HTTPError on non-2xx response.
"""
url = f"http://{host}/v1/audio/speech"
resp = requests.post(
url,
json={"input": text},
timeout=REQUEST_TIMEOUT_S,
)
resp.raise_for_status()
return resp.content
def check_xtts_health(host: str) -> bool:
"""Return True if XTTS-v2 service is reachable and healthy."""
try:
resp = requests.get(f"http://{host}/health", timeout=5)
return resp.status_code == 200
except Exception:
return False
def main() -> int:
parser = argparse.ArgumentParser(
description="Pre-generate NPC TTS pool via XTTS-v2"
)
parser.add_argument("--dry-run", action="store_true",
help="Parse phrases and print plan without calling XTTS")
parser.add_argument("--force", action="store_true",
help="Re-generate files that already exist")
parser.add_argument("--host", default=DEFAULT_HOST,
help=f"XTTS-v2 host:port (default: {DEFAULT_HOST})")
parser.add_argument("--phrases-yaml", default=str(PHRASES_YAML),
help="Path to npc_phrases.yaml")
args = parser.parse_args()
phrases_path = pathlib.Path(args.phrases_yaml)
if not phrases_path.exists():
print(f"ERROR: phrases file not found: {phrases_path}", file=sys.stderr)
return 1
# Parse phrase bank
with open(phrases_path, encoding="utf-8") as f:
data = yaml.safe_load(f)
phrases = flatten_phrases(data)
print(f"Phrase bank: {len(phrases)} phrases found in {phrases_path}")
# Health check before starting (skip in dry-run)
if not args.dry_run:
print(f"Checking XTTS-v2 at {args.host}...", end=" ", flush=True)
if not check_xtts_health(args.host):
print("UNREACHABLE")
print(f"ERROR: XTTS-v2 not reachable at http://{args.host}/health",
file=sys.stderr)
print("Start the service first: docker compose -f tools/tts/docker-compose.xtts.yml up -d",
file=sys.stderr)
return 1
print("OK")
# Load existing manifest
manifest: dict[str, str] = {}
if MANIFEST.exists():
with open(MANIFEST, encoding="utf-8") as f:
manifest = json.load(f)
generated = skipped = errors = 0
for ph in phrases:
key = ph["key"]
text = ph["text"]
path = key_to_path(key)
# Skip if already generated (unless --force)
if not args.force and path.exists():
skipped += 1
manifest[key] = str(path)
continue
label = "[DRY]" if args.dry_run else "[GEN]"
preview = text[:70] + "..." if len(text) > 70 else text
print(f" {label} {key}")
print(f" {preview}")
if args.dry_run:
generated += 1
continue
# Synthesize with retries
wav_bytes: bytes | None = None
for attempt in range(1, RETRY_COUNT + 1):
try:
wav_bytes = synthesize(text, args.host)
break
except Exception as exc:
print(f" WARNING attempt {attempt}/{RETRY_COUNT}: {exc}",
file=sys.stderr)
if attempt < RETRY_COUNT:
time.sleep(RETRY_DELAY_S)
if wav_bytes is None:
print(f" ERROR: all retries failed for key={key}", file=sys.stderr)
errors += 1
continue
# Write file
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(wav_bytes)
manifest[key] = str(path)
generated += 1
except OSError as exc:
print(f" ERROR writing {path}: {exc}", file=sys.stderr)
errors += 1
# Save manifest
if not args.dry_run:
MANIFEST.parent.mkdir(parents=True, exist_ok=True)
with open(MANIFEST, "w", encoding="utf-8") as f:
json.dump(manifest, f, indent=2, ensure_ascii=False)
print(f"\nManifest written: {MANIFEST} ({len(manifest)} entries)")
print(f"\nResult: {generated} generated, {skipped} skipped, {errors} errors")
return 0 if errors == 0 else 1
if __name__ == "__main__":
sys.exit(main())