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>
228 lines
6.8 KiB
Python
228 lines
6.8 KiB
Python
#!/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())
|