feat(gamebook): library generator + bump fw
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:
+1
-1
Submodule ESP32_ZACUS updated: f58b090a34...a70963f64a
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user