616aeca14e
Match the firmware's scrolling body (no more clipped passages) and bump ESP32_ZACUS for the scroll fix + DELETE /game/file.
200 lines
7.7 KiB
Python
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())
|