Explosion_der_Spiele/scripts/tools/generate_voice_dialogs.py
copilot 1cc8aa9b9d Add village 3D wiring, voice lines, and 1.74 m world scale.
Houses ~8.5 m; chimps capped ~0.85 m with safer axis fitting. Large GLB/PBR packs stay local until Git LFS (Forgejo HTTP 413).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 12:02:57 +02:00

228 lines
6.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Generate high-quality Piper TTS for dialog_* / lang_voice_* from locales.
Uses large/high Piper models from data/voice_cast.json.
Until real voice takes exist, run --watch to auto-rebuild when scripts change.
Usage:
python3 scripts/tools/generate_voice_dialogs.py --force
python3 scripts/tools/generate_voice_dialogs.py --watch
python3 scripts/tools/generate_voice_dialogs.py --lang de --force
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
CAST_PATH = ROOT / "data" / "voice_cast.json"
LOCALES = ROOT / "data" / "locales"
OUT_ROOT = ROOT / "assets" / "audio" / "voice"
MANIFEST = OUT_ROOT / "_manifest.json"
PIPER = os.path.expanduser("~/.local/bin/piper")
DEFAULT_VOICES = Path(os.path.expanduser("~/.local/share/piper/voices"))
STAGE_DIR = re.compile(r"\*[^*]+\*")
EMOJI_ETC = re.compile(r"[⬜]+")
def clean_tts(text: str) -> str:
text = STAGE_DIR.sub(" ", text)
text = EMOJI_ETC.sub(" ", text)
text = text.replace("…", "...")
text = text.replace("—", " - ")
text = text.replace("–", " - ")
text = re.sub(r"\s+", " ", text).strip()
return text
def load_cast() -> dict:
return json.loads(CAST_PATH.read_text(encoding="utf-8"))
def voices_dir(cast: dict) -> Path:
return Path(os.path.expanduser(cast.get("voices_dir", str(DEFAULT_VOICES))))
def resolve_model(cast: dict, model_key: str) -> Path:
models = cast["models"]
if model_key not in models:
raise KeyError(f"unknown model key {model_key}")
path = voices_dir(cast) / models[model_key]
return path
def npc_params(cast: dict, npc_id: str, lang: str) -> dict:
npc = cast["npcs"][npc_id]
# New format: per-lang dict; legacy: flat speaker ints
entry = npc.get(lang, npc)
if isinstance(entry, dict) and ("model" in entry or "speaker" in entry):
params = dict(entry)
else:
defaults = cast.get("lang_defaults", {})
params = {
"model": defaults.get(lang, "de_high"),
"speaker": int(entry) if not isinstance(entry, dict) else 0,
"length_scale": float(npc.get("length_scale", 1.0)),
}
params.setdefault("speaker", 0)
params.setdefault("length_scale", 1.0)
params.setdefault("noise_scale", 0.667)
params.setdefault("noise_w", 0.8)
params.setdefault("sentence_silence", 0.35)
return params
def fingerprint(text: str, params: dict, model_path: Path) -> str:
payload = {
"text": text,
"model": model_path.name,
"speaker": int(params.get("speaker", 0)),
"length_scale": float(params.get("length_scale", 1.0)),
"noise_scale": float(params.get("noise_scale", 0.667)),
"noise_w": float(params.get("noise_w", 0.8)),
"sentence_silence": float(params.get("sentence_silence", 0.35)),
}
raw = json.dumps(payload, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
def synthesize(model: Path, text: str, out_wav: Path, params: dict) -> None:
out_wav.parent.mkdir(parents=True, exist_ok=True)
tmp = out_wav.with_suffix(".tmp.wav")
cmd = [
PIPER,
"-m", str(model),
"-s", str(int(params.get("speaker", 0))),
"--length_scale", str(float(params.get("length_scale", 1.0))),
"--noise_scale", str(float(params.get("noise_scale", 0.667))),
"--noise_w", str(float(params.get("noise_w", 0.8))),
"--sentence_silence", str(float(params.get("sentence_silence", 0.35))),
"-f", str(tmp),
]
proc = subprocess.run(cmd, input=text.encode("utf-8"), capture_output=True)
if proc.returncode != 0 or not tmp.exists() or tmp.stat().st_size < 44:
err = proc.stderr.decode("utf-8", errors="replace")
if tmp.exists():
tmp.unlink(missing_ok=True)
raise RuntimeError(f"piper failed for {out_wav.name}: {err[-500:]}")
tmp.replace(out_wav)
def load_manifest() -> dict:
if MANIFEST.exists():
try:
return json.loads(MANIFEST.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return {}
return {}
def save_manifest(data: dict) -> None:
OUT_ROOT.mkdir(parents=True, exist_ok=True)
MANIFEST.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
def generate(force: bool = False, langs: list[str] | None = None) -> tuple[int, int]:
if not Path(PIPER).exists():
raise SystemExit(f"piper not found at {PIPER}")
cast = load_cast()
lines = cast["lines"]
all_langs = langs or ["de", "en", "by"]
locale_data = {}
for lang in all_langs:
locale_data[lang] = json.loads((LOCALES / f"{lang}.json").read_text(encoding="utf-8"))
manifest = {} if force else load_manifest()
done = 0
skipped = 0
for lang in all_langs:
print(f"== {lang}", flush=True)
for key, npc_id in lines.items():
text_raw = locale_data[lang].get(key)
if not text_raw:
print(f" skip missing {key}")
skipped += 1
continue
tts = clean_tts(str(text_raw))
if not tts:
skipped += 1
continue
params = npc_params(cast, npc_id, lang)
model_path = resolve_model(cast, str(params["model"]))
if not model_path.exists():
raise SystemExit(f"MISSING model {model_path}")
fp = fingerprint(tts, params, model_path)
out = OUT_ROOT / lang / f"{key}.wav"
mkey = f"{lang}/{key}.wav"
if (
not force
and out.exists()
and out.stat().st_size > 1000
and manifest.get(mkey) == fp
):
skipped += 1
continue
print(f" {key} npc={npc_id} model={model_path.name} spk={params.get('speaker')}")
synthesize(model_path, tts, out, params)
manifest[mkey] = fp
done += 1
save_manifest(manifest)
print(f"generated={done} skipped={skipped} out={OUT_ROOT}", flush=True)
return done, skipped
def watch_sources_mtime() -> float:
mtimes = [CAST_PATH.stat().st_mtime]
for p in LOCALES.glob("*.json"):
mtimes.append(p.stat().st_mtime)
return max(mtimes)
def watch_loop(interval: float = 2.0) -> int:
print(f"watching {LOCALES} + {CAST_PATH} (interval={interval}s) — Ctrl+C to stop", flush=True)
print("placeholder TTS only; replace WAVs later with real takes (same paths).", flush=True)
last = 0.0
# initial sync
generate(force=False)
last = watch_sources_mtime()
try:
while True:
time.sleep(interval)
now = watch_sources_mtime()
if now > last:
print("\n[watch] locale/cast changed — regenerating…")
generate(force=False)
last = watch_sources_mtime()
except KeyboardInterrupt:
print("\n[watch] stopped")
return 0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--force", action="store_true", help="Rebuild all WAVs")
ap.add_argument("--lang", action="append", help="Only these langs (de/en/by)")
ap.add_argument("--watch", action="store_true", help="Auto-regenerate when locales/cast change")
ap.add_argument("--interval", type=float, default=2.0, help="Watch poll seconds")
args = ap.parse_args()
if args.watch:
return watch_loop(args.interval)
generate(force=args.force, langs=args.lang)
return 0
if __name__ == "__main__":
raise SystemExit(main())