From af5d99e901e0bff7f4e1ce5cb5bc3e3452e5ec66 Mon Sep 17 00:00:00 2001 From: clement Date: Sat, 20 Jun 2026 01:10:17 +0200 Subject: [PATCH] feat(gamebook): Kyutai backend for master gen Add --tts say|kyutai to build_gamebook.py (the screen/BOX-3 generator), mirroring the phone one: POST to the local Kyutai :8302 server, resample 24->16k via afconvert. Lets the BOX-3 use the nicer neural FR voice. --- ESP32_ZACUS | 2 +- tools/gamebook/build_gamebook.py | 40 ++++++++++++++++++++++++++++---- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/ESP32_ZACUS b/ESP32_ZACUS index fd7af95..5519ed2 160000 --- a/ESP32_ZACUS +++ b/ESP32_ZACUS @@ -1 +1 @@ -Subproject commit fd7af95bcfcc24f369b916a045fa27bece86597c +Subproject commit 5519ed2f724cc2460bd440b47d6b69441e958341 diff --git a/tools/gamebook/build_gamebook.py b/tools/gamebook/build_gamebook.py index 13b2bef..73290a4 100644 --- a/tools/gamebook/build_gamebook.py +++ b/tools/gamebook/build_gamebook.py @@ -29,12 +29,15 @@ import json import struct import subprocess import sys +import tempfile import unicodedata import urllib.request from pathlib import Path import yaml +KYUTAI_URL = "http://127.0.0.1:8302/tts" # local Kyutai TTS 1.6B en_fr (MLX) + REPO = Path(__file__).resolve().parents[2] BOOKS_DIR = REPO / "game" / "gamebooks" OUT_DIR = REPO / "tools" / "gamebook" / "build" @@ -70,10 +73,36 @@ def clean_wav(raw: bytes) -> bytes: + b"data" + struct.pack(" bool: +def _kyutai_tts(text: str, out: Path) -> bool: + """Local Kyutai TTS 1.6B (en_fr) → 24 kHz WAV, resampled to 16 kHz mono via + afconvert (native macOS), then canonicalised. High-quality French voice.""" + req = urllib.request.Request( + KYUTAI_URL, data=json.dumps({"text": text}).encode(), + headers={"Content-Type": "application/json"}, method="POST") + raw = urllib.request.urlopen(req, timeout=300).read() # synth is slow + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: + tmp.write(raw) + tmp_path = tmp.name + subprocess.run(["afconvert", "-f", "WAVE", "-d", "LEI16@16000", "-c", "1", + tmp_path, str(out)], check=True, capture_output=True, timeout=60) + Path(tmp_path).unlink(missing_ok=True) + out.write_bytes(clean_wav(out.read_bytes())) + return out.stat().st_size > 44 + + +def say_wav(text: str, voice: str, out: Path, force: bool, + backend: str = "say") -> bool: + """Synthesise to a 16 kHz mono WAV. Cached by filename (resumable); + backend "kyutai" uses the local neural FR voice, else macOS say.""" if out.exists() and out.stat().st_size > 44 and not force: return True out.parent.mkdir(parents=True, exist_ok=True) + if backend == "kyutai": + try: + return _kyutai_tts(text, out) + except Exception as exc: # noqa: BLE001 + print(f" ! kyutai failed: {str(exc)[:140]}", file=sys.stderr) + return False for v in (voice, DEFAULT_VOICE): try: subprocess.run( @@ -115,7 +144,8 @@ def narration(text: str, choices: list[dict]) -> str: def build_book(path: Path, force: bool, dry: bool, ip: str, - json_only: bool = False, narrate_text: bool = False) -> dict | None: + json_only: bool = False, narrate_text: bool = False, + tts: str = "say") -> dict | None: """Synthesise + (optionally) stage one book. Returns its library entry.""" bid = path.stem book = yaml.safe_load(path.read_text()) @@ -139,7 +169,7 @@ def build_book(path: Path, force: bool, dry: bool, ip: str, # as tappable buttons, so they don't need to be spoken). spoken = (" ".join(p["text"].split()) if narrate_text else narration(p["text"], choices)) - if not say_wav(spoken, voice, out, force): + if not say_wav(spoken, voice, out, force, tts): print(f" ✗ synth {bid}/{pid}"); n_fail += 1; continue n_ok += 1 manifest["passages"][pid] = { @@ -176,6 +206,8 @@ def main() -> int: ap.add_argument("--narrate-text", action="store_true", help="WAV narration = passage text only (no spoken choices) — for the BOX-3") ap.add_argument("--only", default="", help="comma-separated book ids to build") + ap.add_argument("--tts", choices=["say", "kyutai"], default="say", + help="TTS backend (kyutai = local neural FR, much nicer/slower)") args = ap.parse_args() books = sorted(p for p in BOOKS_DIR.glob("*.yaml") if p.stem not in EXCLUDE) @@ -190,7 +222,7 @@ def main() -> int: fail = 0 for path in build_books: e = build_book(path, args.force, args.dry_run, args.ip, args.json_only, - args.narrate_text) + args.narrate_text, args.tts) if e is None: fail += 1 continue