Files
le-mystere-professeur-zacus/tools/gamebook/build_gamebook.py
T
clement 616aeca14e
Repo State / repo-state (push) Failing after 1m0s
Validate Zacus refactor / validate (push) Failing after 6m3s
feat(gamebook): screen text cap 1000 + bump fw
Match the firmware's scrolling body (no more clipped passages) and bump
ESP32_ZACUS for the scroll fix + DELETE /game/file.
2026-06-19 16:31:03 +02:00

200 lines
7.7 KiB
Python

#!/usr/bin/env python3
"""Compile the gamebook LIBRARY for the Freenove master.
Builds every "livre dont vous etes le heros" in game/gamebooks/*.yaml (minus
the zacus_demo tech demo) into:
* one WAV per passage, rendered with macOS `say` (16 kHz mono, canonical
header — Apple FLLR chunk stripped so the firmware decodes it), named
flat: <bookid>_<passage>.wav
* one book manifest per story: <bookid>.json {title,start,passages{...}}
* a library index: library.json [{id,title,book}]
…and stages them all onto the master SD at /sdcard/gamebook/ via the firmware
`POST /game/file?path=sd/gamebook/<name>` endpoint.
On boot the firmware reads library.json, shows the stories as tiles, and on
selection loads the chosen <bookid>.json and plays it. Fully offline, no model.
Navigation is generic (D-pad moves a cursor/tile, click confirms), so choices
carry only {label, goto}.
Usage:
python3 tools/gamebook/build_gamebook.py # build+stage all -> .188
python3 tools/gamebook/build_gamebook.py --dry-run # synth only
python3 tools/gamebook/build_gamebook.py --ip 192.168.0.188 --force
"""
from __future__ import annotations
import argparse
import json
import struct
import subprocess
import sys
import unicodedata
import urllib.request
from pathlib import Path
import yaml
REPO = Path(__file__).resolve().parents[2]
BOOKS_DIR = REPO / "game" / "gamebooks"
OUT_DIR = REPO / "tools" / "gamebook" / "build"
DEFAULT_VOICE = "Thomas"
EXCLUDE = {"zacus_demo"} # dev demo, not part of the player library
MAX_TILES = 6 # the firmware shows a 2x3 tile grid
def fold(s: str) -> str:
"""ASCII-fold (the embedded LCD fonts have no accents); the WAV keeps them."""
return unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode()
def clean_wav(raw: bytes) -> bytes:
"""Rebuild a canonical 44-byte-header PCM WAV (strip Apple's FLLR filler)."""
if raw[:4] != b"RIFF" or raw[8:12] != b"WAVE":
return raw
i, fmt, pcm = 12, None, None
while i + 8 <= len(raw):
cid = raw[i:i + 4]
sz = int.from_bytes(raw[i + 4:i + 8], "little")
body = raw[i + 8:i + 8 + sz]
if cid == b"fmt ":
fmt = body[:16]
elif cid == b"data":
pcm = body
break
i += 8 + sz + (sz & 1)
if fmt is None or pcm is None:
return raw
return (b"RIFF" + struct.pack("<I", 4 + 8 + len(fmt) + 8 + len(pcm)) + b"WAVE"
+ b"fmt " + struct.pack("<I", len(fmt)) + fmt
+ b"data" + struct.pack("<I", len(pcm)) + pcm)
def say_wav(text: str, voice: str, out: Path, force: bool) -> bool:
if out.exists() and out.stat().st_size > 44 and not force:
return True
out.parent.mkdir(parents=True, exist_ok=True)
for v in (voice, DEFAULT_VOICE):
try:
subprocess.run(
["say", "-v", v, "--file-format=WAVE",
"--data-format=LEI16@16000", "-o", str(out), text],
check=True, capture_output=True, timeout=90,
)
out.write_bytes(clean_wav(out.read_bytes()))
return out.stat().st_size > 44
except subprocess.CalledProcessError:
continue
except Exception as exc: # noqa: BLE001
print(f" ! say failed ({v}): {exc}", file=sys.stderr)
return False
return False
def push(ip: str, name: str, data: bytes) -> tuple[bool, str]:
url = f"http://{ip}/game/file?path=sd/gamebook/{name}"
req = urllib.request.Request(url, data=data,
headers={"Content-Type": "application/octet-stream"},
method="POST")
try:
return True, urllib.request.urlopen(req, timeout=25).read().decode()
except Exception as exc: # noqa: BLE001
body = getattr(exc, "read", lambda: b"")()
return False, f"{exc} {body.decode('utf-8','replace') if body else ''}".strip()
def narration(text: str, choices: list[dict]) -> str:
parts = [" ".join(text.split())]
if choices:
parts.append("À toi de choisir :")
parts.append(" ; ".join(c["label"] for c in choices) + ".")
parts.append("Déplace la sélection avec les flèches, puis clique pour valider.")
else:
parts.append("Fin de ce chemin. Clique pour revenir à la bibliothèque.")
return " ".join(parts)
def build_book(path: Path, force: bool, dry: bool, ip: str) -> dict | None:
"""Synthesise + (optionally) stage one book. Returns its library entry."""
bid = path.stem
book = yaml.safe_load(path.read_text())
voice = book.get("voice", DEFAULT_VOICE)
passages = book["passages"]
start = book["start"]
if start not in passages:
print(f" ! {bid}: start '{start}' absent"); return None
manifest = {"title": book.get("title", bid), "start": start, "passages": {}}
n_ok = n_push = n_fail = 0
for pid, p in passages.items():
choices = p.get("choices") or []
for c in choices:
if c["goto"] not in passages:
print(f" ! {bid}/{pid} -> '{c['goto']}' inconnu"); return None
wav = f"{bid}_{pid}.wav"
out = OUT_DIR / wav
if not say_wav(narration(p["text"], choices), voice, out, force):
print(f" ✗ synth {bid}/{pid}"); n_fail += 1; continue
n_ok += 1
manifest["passages"][pid] = {
"wav": wav,
"screen": fold(p.get("screen", ""))[:47],
"text": fold(" ".join(p["text"].split()))[:1000],
"choices": [{"label": fold(c["label"])[:38], "goto": c["goto"]}
for c in choices],
}
if not dry:
good, _ = push(ip, wav, out.read_bytes())
n_push += 1 if good else 0
n_fail += 0 if good else 1
bookfile = f"{bid}.json"
(OUT_DIR / bookfile).write_text(json.dumps(manifest, ensure_ascii=False))
if not dry:
good, resp = push(ip, bookfile, (OUT_DIR / bookfile).read_bytes())
n_fail += 0 if good else 1
print(f" {bid:18s} {len(passages):2d} passages synth={n_ok} fail={n_fail}"
+ ("" if dry else f" pushed={n_push}"))
return {"id": bid, "title": fold(book.get("title", bid))[:40], "book": bookfile,
"_fail": n_fail}
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--ip", default="192.168.0.188", help="master IP (default .188)")
ap.add_argument("--force", action="store_true", help="re-synthesise cached WAVs")
ap.add_argument("--dry-run", action="store_true", help="synthesise only, no push")
args = ap.parse_args()
books = sorted(p for p in BOOKS_DIR.glob("*.yaml") if p.stem not in EXCLUDE)
if len(books) > MAX_TILES:
print(f"note: {len(books)} books found, library shows first {MAX_TILES}")
books = books[:MAX_TILES]
print(f"library: {len(books)} books -> "
f"{'(dry-run)' if args.dry_run else f'{args.ip}:/sdcard/gamebook/'}")
entries, fail = [], 0
for path in books:
e = build_book(path, args.force, args.dry_run, args.ip)
if e is None:
fail += 1
continue
fail += e.pop("_fail")
entries.append(e)
library = {"library": entries}
(OUT_DIR / "library.json").write_text(json.dumps(library, ensure_ascii=False, indent=1))
if not args.dry_run:
good, resp = push(args.ip, "library.json", (OUT_DIR / "library.json").read_bytes())
print(f" {'' if good else ''} library.json -> {resp[:80]}")
fail += 0 if good else 1
print(f"\nbooks={len(entries)} fail={fail}")
print(f"local: {OUT_DIR}")
return 1 if fail else 0
if __name__ == "__main__":
raise SystemExit(main())