feat(gamebook): Kyutai backend for master gen
Validate Zacus refactor / validate (pull_request) Failing after 7m8s
Repo State / repo-state (pull_request) Failing after 10m4s
Validate Zacus refactor / validate (push) Failing after 14m8s
Repo State / repo-state (push) Failing after 14m24s

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.
This commit was merged in pull request #190.
This commit is contained in:
clement
2026-06-20 01:10:17 +02:00
parent 0c1802285a
commit af5d99e901
2 changed files with 37 additions and 5 deletions
+36 -4
View File
@@ -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("<I", len(pcm)) + pcm)
def say_wav(text: str, voice: str, out: Path, force: bool) -> 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