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.
Context:
The gamebook tiles were filled with fairly dark adventures (haunted
manor, deaths, being eaten/frozen) — not suitable for an 11-year-old.
We want the boot library to be age-appropriate.
Approach:
Write six light, branching stories pitched at ~11 years (adventure +
humour, gentle "oops, try again" failures, never grim), and move the
six mature adventures into a non-library subfolder so they're kept but
out of the kids' picker (the generator globs game/gamebooks/*.yaml,
non-recursive). Text only — no WAV synthesis here.
Changes:
- New kid stories in game/gamebooks/: dragon_flamme, tresor_caramel,
planete_zigzag, hamster_disparu, ecole_sorciers, voyage_dinos
(11-17 passages each, 2-5 endings, all reachable, no broken gotos).
- Move the 6 mature adventures to game/gamebooks/aventures/ (archived,
excluded from the library glob).
Impact:
The 6-tile boot library now offers age-appropriate stories. Build/stage
them with tools/gamebook/build_gamebook.py when ready (not done here).
Context:
The gamebook engine had a single short demo. We want several long,
text-only "livre dont vous etes le heros" adventures to play on the
Freenove (and to exercise deeper branching).
Approach:
Author each as a standalone gamebook YAML in game/gamebooks/, using the
navigable format (passages with screen/text, choices as label+goto;
a choiceless passage is an ending that restarts on click).
Changes:
- game/gamebooks/tour_horloger.yaml: "La Tour de l'Horloger"
(fantastique/temps) — 23 passages, 8 fins.
- game/gamebooks/train_de_nuit.yaml: "Le Dernier Train de Nuit"
(mystere/huis clos) — 19 passages, 7 fins.
- game/gamebooks/ile_echos.yaml: "L'Ile aux Mille Echos"
(aventure/survie) — 16 passages, 7 fins.
Impact:
Three richly branched stories ready to stage on the master SD with
tools/gamebook/build_gamebook.py. Graphs validated: no broken gotos,
every passage reachable, multiple endings each.
Context:
The new firmware gamebook mode needs a book on the Freenove SD, plus a
reusable way to author one. We also want a playable demo to prove the
whole chain (button -> navigation -> WAV narration from SD -> screen).
Approach:
Author the book in a human-friendly YAML; a generator renders each
passage's narration with macOS say (16 kHz mono, canonical WAV — the
Apple FLLR chunk is stripped so the firmware parser decodes it),
emits a compact gamebook.json, and stages WAVs + JSON onto the master
SD via POST /game/file?path=sd/gamebook/.
Changes:
- game/gamebooks/zacus_demo.yaml: a branching mini-adventure (the
Professeur Zacus lab) — 8 passages, 3 endings.
- tools/gamebook/build_gamebook.py: reusable/idempotent generator
(--book/--ip/--sd-dir/--dry-run); ASCII-folds on-screen text (fonts
have no accents) while the WAV keeps the accented narration; choices
carry label + goto (navigation is generic, D-pad + click).
- .gitignore: ignore the generated WAV pack (tools/gamebook/build/).
- Bump ESP32_ZACUS to the gamebook-mode firmware.
Impact:
Authors can write a gamebook in YAML and stage it to the Freenove in
one command; the bundled demo plays end to end, fully offline.
Auto-call now forwards the scene to /debug/ring so the phone plays
that scene's hint from its local SD pack after the greeting. Bumps
ESP32_ZACUS for the chained-hint firmware.
macOS say inserts a ~4 KB FLLR filler chunk between fmt and data. The
PLIP firmware parses only a small header buffer and never reaches a
data chunk pushed past it (WAV parse failed -> silent). Rebuild a
canonical 44-byte-header RIFF/fmt/data WAV before staging. Verified:
Zacus greeting plays 5.05s from /sdcard on pickup, no gateway/model.
Pre-render directory greetings + scene hints with macOS say (16 kHz
mono FR) and stage them on the PLIP microSD via POST /game/file — gives
the phone local voice content with no model in RAM. Reusable/idempotent
(tools/tts/generate_plip_sd_pack.py). Bumps ESP32_ZACUS for the SD write
path. Generated WAVs gitignored (reproducible).
The phone now rings on cue from the game with NO firmware change. A
call_on_scene map (phone_directory.yaml: {SCENE_X: number}) tells the
gateway which scenes are scripted incoming calls; since the runtime
already pushes the scene to /game/step, the gateway auto-rings the PLIP
with that NPC on scene entry (deduped, once per entry). Factored a
_ring_board helper (shared by /game/call + auto-ring). Map ships empty
(no surprise rings) with commented examples for the designer.
The gateway now hints on the SCENE the runtime pushes (idf_zacus POSTs
its native SCENE_* to /game/step?scene=…) — no step<->scene mapping
needed since both sides use the same SCENE_* ids. _active_scene() uses
the pushed scene (falling back to the current step's scene). Bump
ESP32_ZACUS to 653a299 (npc_engine pushes scene changes).
The runtime triggers a scenario phone call: POST /game/call?number=18
[&step=STEP_X] rings the PLIP handset with the chosen NPC (resolved via
the boards registry) and sets the current step so the NPC disguises that
step's hint. Completes the game->phone trigger path (chantier #2).
Wire the phone NPCs to the scenario state machine + hint bank so a call
ADVANCES the puzzle instead of being generic chat. load_scenario_hints
maps each step (zacus_v3.yaml screen_scene_id) to npc_phrases.yaml
hints[scene][level]; voice/reply weaves the CURRENT step's hint into the
NPC persona, escalating the level (subtle->near-explicit) with the
conversation. New GET/POST /game/step lets the runtime set the current
step as the player progresses.
Condition the handset capture by RMS (not peak) before whisper: the
voice body is ~0.5-1.5% FS and a transient peak defeated peak-normalise,
leaving it inaudible. RMS-normalise to ~20% FS + clip. Recovers weak
captures whisper returned '' for (verified: -> 'C'est parti !').
Switch the voice-loop STT from Kyutai to whisper.cpp large-v3-turbo
(subprocess, high-pass conditioned): it decodes the weak/noisy SLIC
handset captures Kyutai returned '' for. Filter whisper's silence/noise
hallucinations (sous-titrage, radio-canada, tipeurs, '...') so the NPC
never replies to a phantom line. Bump ESP32_ZACUS to 54022ed (VAD
recalibration for the quiet voice + bell stop on pickup).
Gateway: drop the remote ailiance fallback — the NPC LLM is now the
on-box granite ONLY (local spoken line on failure, never remote).
Prewarm the persona KV-cache in the background at greeting time so the
first reply is fast instead of paying ~17 s of cold prompt processing.
Add temperature + repetition_penalty so granite4-tiny stops looping.
STT: cut the trailing-silence pad 2 s -> 0.5 s (callers already pad),
shaving STT latency. Bump ESP32_ZACUS to cfe429d (hook debounce).
Bump ESP32_ZACUS to 82759ee (voice-activated two-phase capture, mic
diag). Gateway: drop the local-LLM (granite) timeout 70s -> 8s so a
cold/hung local model fails over to the remote backend fast instead of
stalling the whole turn past the firmware's reply timeout.
Pull in ESP32_ZACUS fix(plip) 8c076d8: gate audio playback on the real
SHK line and remove all hook forcing (s_hook_override,
phone_force_offhook, /debug/offhook). Fixes the silent incoming-call
greeting caused by the two off-hook flags desyncing during ring.
The Lm holds a persistent rotating transformer_cache shared by every
LmGen. LmGen only resets its own gen_sequence/step_idx, so across
requests the cache kept accumulating stream positions until it
saturated and the model emitted only padding tokens, returning empty
transcriptions ('') for every request. Reset transformer_cache (and
depformer_cache) per request, mirroring Lm.warmup(). Validated: 40
consecutive requests of a known-good sample, 0 empty.
SLIC handset injects low-frequency rumble/DC drift that swamps speech
and causes Kyutai STT to output nothing. Apply a box moving-average
high-pass (~110 Hz) before peak-normalisation. Also raise gain cap to
x40 and target_peak to 0.7 (mic is still very quiet post-ES8388 fix).
Verified: same capture transcribes empty without HP, clean French with.
Add _wav_pcm(), _build_wav(), _normalize_wav_for_stt() (peak-normalize
with capped gain) to handle the PLIP handset mic low signal level and
placeholder 0xFFFFFFFF WAV headers from /voice/capture stream.
Rewire _pad_wav_silence() and _transcribe_kyutai via these helpers.
- Replace Kyutai TTS with macOS say (16 kHz mono FR);
NPC voice map _SAY_VOICE_MAP; helper _tts_say_sync
- Local vllm-mlx :8520 as primary LLM (_chat_one),
ailiance as fallback, spoken phrase as last resort;
_chat_reply never raises (loop stays alive on 502)
- Pad 800 ms silence before /transcribe (_pad_wav_silence)
to prevent last-word truncation
- Background _keep_llm_warm ping every 600 s (cold start)
- Root log level INFO; X-Zacus-Heard/Said headers
- test_chat_reply: empty_choices expects spoken fallback