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.
This commit was merged in pull request #190.
This commit is contained in:
+1
-1
Submodule ESP32_ZACUS updated: fd7af95bcf...5519ed2f72
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user