feat(gamebook): library generator + bump fw
Repo State / repo-state (pull_request) Failing after 38s
Repo State / repo-state (push) Failing after 29s
Validate Zacus refactor / validate (pull_request) Failing after 5m44s
Validate Zacus refactor / validate (push) Failing after 5m58s

Build all game/gamebooks/*.yaml into per-book <id>.json + a library.json
index (flat <id>_<passage>.wav naming), staged to /sdcard/gamebook/.
Bumps ESP32_ZACUS for the boot tile picker.
This commit was merged in pull request #182.
This commit is contained in:
clement
2026-06-19 15:25:04 +02:00
parent a9953172e4
commit 24de4de4e2
2 changed files with 93 additions and 74 deletions
+92 -73
View File
@@ -1,22 +1,26 @@
#!/usr/bin/env python3
"""Compile a "livre dont vous êtes le héros" gamebook for the Freenove master.
"""Compile the gamebook LIBRARY for the Freenove master.
Takes a human-friendly YAML gamebook (game/gamebooks/*.yaml), renders each
passage's narration to a WAV with macOS `say` (16 kHz mono, canonical header —
no Apple FLLR chunk so the firmware decodes it), emits a compact gamebook.json,
and stages both onto the master's microSD at /sdcard/gamebook/ via the firmware
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.
The phone-less gamebook runs fully on the Freenove: WAV narration from SD +
5-way button navigation, NO model in RAM.
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.
Button keys (5-way ladder on GPIO19): select=1 down=2 menu=3 leftright=4 up=5.
`menu` is reserved to quit, so choices use: select / up / down / leftright.
Navigation is generic (D-pad moves a cursor/tile, click confirms), so choices
carry only {label, goto}.
Usage:
python3 tools/gamebook/build_gamebook.py # zacus_demo → .188
python3 tools/gamebook/build_gamebook.py --book game/gamebooks/zacus_demo.yaml
python3 tools/gamebook/build_gamebook.py --dry-run # synth only
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
@@ -31,21 +35,21 @@ 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 the
accented text — only the on-screen copy is folded."""
"""ASCII-fold (the embedded LCD fonts have no accents); the WAV keeps them."""
return unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode()
REPO = Path(__file__).resolve().parents[2]
OUT_DIR = REPO / "tools" / "gamebook" / "build"
DEFAULT_BOOK = REPO / "game" / "gamebooks" / "zacus_demo.yaml"
DEFAULT_VOICE = "Thomas"
def clean_wav(raw: bytes) -> bytes:
"""Rebuild a canonical 44-byte-header PCM WAV (strip Apple's FLLR filler so
the firmware's minimal parser reaches the data chunk)."""
"""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
@@ -66,7 +70,9 @@ def clean_wav(raw: bytes) -> bytes:
+ b"data" + struct.pack("<I", len(pcm)) + pcm)
def say_wav(text: str, voice: str, out: Path) -> bool:
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:
@@ -85,8 +91,8 @@ def say_wav(text: str, voice: str, out: Path) -> bool:
return False
def push(ip: str, sd_dir: str, name: str, data: bytes) -> tuple[bool, str]:
url = f"http://{ip}/game/file?path=sd/{sd_dir}/{name}"
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")
@@ -98,81 +104,94 @@ def push(ip: str, sd_dir: str, name: str, data: bytes) -> tuple[bool, str]:
def narration(text: str, choices: list[dict]) -> str:
"""Spoken line = passage text + a spoken choice list. Navigation is generic
(D-pad to move the cursor, click to confirm) so we just read the options."""
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 recommencer l'aventure.")
parts.append("Fin de ce chemin. Clique pour revenir à la bibliothèque.")
return " ".join(parts)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--book", default=str(DEFAULT_BOOK), help="gamebook YAML")
ap.add_argument("--ip", default="192.168.0.188", help="master IP (default .188)")
ap.add_argument("--sd-dir", default="gamebook", help="subdir under /sdcard")
ap.add_argument("--dry-run", action="store_true", help="synthesise only, no push")
args = ap.parse_args()
book = yaml.safe_load(Path(args.book).read_text())
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"start passage '{start}' not in book", file=sys.stderr)
return 1
print(f"gamebook: \"{book.get('title','?')}\"{len(passages)} passages, "
f"voice {voice}, start={start} -> "
f"{'(dry-run)' if args.dry_run else f'{args.ip}:/sdcard/{args.sd_dir}/'}")
manifest = {"title": book.get("title", ""), "start": start, "passages": {}}
ok = fail = pushed = 0
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: # navigation is generic; only goto matters
choices = p.get("choices") or []
for c in choices:
if c["goto"] not in passages:
print(f" ! {pid}: choice -> unknown passage '{c['goto']}'"); return 1
wav_name = f"{pid}.wav"
out = OUT_DIR / wav_name
if not say_wav(narration(p["text"], choices), voice, out):
print(f" ✗ synth {pid}"); fail += 1; continue
ok += 1
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_name,
"wav": wav,
"screen": fold(p.get("screen", ""))[:47],
"text": fold(" ".join(p["text"].split()))[:500],
"choices": [{"label": fold(c["label"])[:38], "goto": c["goto"]}
for c in choices],
}
size = out.stat().st_size
if args.dry_run:
print(f" · {pid:12s} {size:>7d} o {len(choices)} choix")
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
good, resp = push(args.ip, args.sd_dir, wav_name, out.read_bytes())
pushed += 1 if good else 0
print(f" {'' if good else ''} {pid:12s} {size:>7d} o "
f"{len(choices)} choix" + ("" if good else f" {resp[:80]}"))
if not good:
fail += 1
fail += e.pop("_fail")
entries.append(e)
mpath = OUT_DIR / "gamebook.json"
mpath.write_text(json.dumps(manifest, ensure_ascii=False, indent=1))
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, args.sd_dir, "gamebook.json", mpath.read_bytes())
print(f" {'' if good else ''} gamebook.json -> {resp[:80]}")
if not good:
fail += 1
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"\nsynth ok={ok} fail={fail}" + ("" if args.dry_run else f" pushed={pushed}"))
print(f"local: {OUT_DIR} ({mpath.name} = {mpath.stat().st_size} o)")
print(f"\nbooks={len(entries)} fail={fail}")
print(f"local: {OUT_DIR}")
return 1 if fail else 0