Explosion_der_Spiele/scripts/tools/generate_voice_dialogs.py

229 lines
6.8 KiB
Python
Raw Permalink Normal View History

#!/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())