Compare commits

...

3 Commits

Author SHA1 Message Date
clement fd7af95bcf feat(box3): audio narration for the touch gamebook
CI / platformio (push) Failing after 6m54s
CI / platformio (pull_request) Failing after 16m36s
Context:
The BOX-3 touch gamebook showed text + tappable choices but was silent.
We want narration through its ES8311 speaker while the text is on
screen — and tapping a choice must cut it instantly.

Approach:
Add a dedicated async, interruptible WAV player to cmd_exec (which owns
the speaker handle), streaming straight from the SD in small chunks so
any length plays without a big malloc. The gamebook plays each passage
WAV on render; a new tap (or returning to the menu) stops it.

Changes:
- cmd_exec.c/.h: cmd_exec_play_file_async(path) + cmd_exec_stop_play().
  One persistent task streams a 16 kHz mono 16-bit WAV from the SD in
  2 KB chunks (RAM-safe), and a new request / stop flag breaks the
  stream mid-clip so playback is interruptible.
- gamebook.c: on entering a passage, play /sdcard/gamebook/<wav> if the
  pack has audio (non-fatal when absent → stays text-only); stop the
  narration when returning to the library.

Impact:
The BOX-3 reads the story aloud (16 kHz, matches AUDIO_SAMPLE_RATE)
while showing the text, and a tap cuts to the next passage. Audio is
optional — text-only packs still work.
2026-06-20 01:02:29 +02:00
clement 5519ed2f72 feat(box3): touch gamebook (tap to choose)
CI / platformio (pull_request) Failing after 9m57s
CI / platformio (push) Failing after 14m8s
Context:
The Freenove gamebook is screen+buttons, the PLIP is audio+dial. The
ESP32-S3-BOX-3 has a 320x240 touchscreen, so its natural format is a
tap-to-choose "livre dont tu es le heros": read the passage on screen,
tap a choice button to navigate.

Approach:
A new gamebook module reuses the same SD pack as the master (the JSON
passages already carry screen/text/choices[label,goto]) and builds the
UI on the already-running LVGL stack: a scrollable column with the
title, the wrapped passage text, and one tappable button per choice.

Changes:
- New gamebook.c/.h: mounts the SD (bsp_sdcard_mount), reads
  /sdcard/gamebook/{library.json, <id>.json}, shows a story list, then
  per passage renders title + text + choice buttons; tapping a choice
  goes to its target, an ending offers "back to menu". cJSON runs on
  PSRAM hooks (the expanded books are ~80 KB JSON); LVGL calls are
  wrapped in bsp_display_lock at init (button callbacks already run in
  the LVGL task).
- main.c: call gamebook_init() after the scenario server is up; it
  takes over the screen when a pack is on the SD, else no-op (the phone
  UI stays).
- CMakeLists: build gamebook.c.

Impact:
With a JSON pack on its SD, the BOX-3 is a touch gamebook: tap a story,
tap your choices. Text-only (no audio needed). Hardware-validated:
boots into a 6-story touch library.
2026-06-20 00:37:13 +02:00
clement a4efce4c20 fix(plip): raise /game/file MAX_BODY to 3 MB
CI / platformio (pull_request) Failing after 4m23s
CI / platformio (push) Failing after 6m6s
Neural-TTS (Kyutai) gamebook narration WAVs run ~1.5 MB per passage at
16 kHz, over the old 512 KB cap. Streamed in 2 KB chunks so the larger
cap stays RAM-safe.
2026-06-20 00:19:46 +02:00
7 changed files with 347 additions and 3 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
idf_component_register(
SRCS "main.c" "voice_ws_client.c" "scenario_server.c" "plip_virtual.c" "plip_ui.c" "stimulus.c" "cmd_exec.c"
SRCS "main.c" "voice_ws_client.c" "scenario_server.c" "plip_virtual.c" "plip_ui.c" "stimulus.c" "cmd_exec.c" "gamebook.c"
INCLUDE_DIRS "."
PRIV_REQUIRES
driver
+68
View File
@@ -488,6 +488,74 @@ static void play_task(void *arg)
vTaskDelete(NULL);
}
// ─── Gamebook narration player (async, streamed, interruptible) ──────────────
// One persistent task plays one WAV at a time, streaming straight from the SD
// in small chunks (no full-file malloc → any length, RAM-safe). A new request
// or a stop flag breaks the stream so tapping a choice cuts the narration.
static TaskHandle_t s_gb_task = NULL;
static char s_gb_path[160];
static volatile bool s_gb_new = false;
static volatile bool s_gb_stop = false;
static void gb_play_task(void *arg)
{
(void) arg;
uint8_t hdr[64];
uint8_t buf[2048];
for (;;) {
if (!s_gb_new) { vTaskDelay(pdMS_TO_TICKS(20)); continue; }
char path[160];
strncpy(path, s_gb_path, sizeof(path) - 1);
path[sizeof(path) - 1] = '\0';
s_gb_new = false;
s_gb_stop = false;
ensure_sd_mounted();
FILE *f = fopen(path, "rb");
if (!f) { ESP_LOGW(TAG, "gb: open %s failed", path); continue; }
size_t hn = fread(hdr, 1, sizeof(hdr), f);
wav_info_t wi;
if (parse_wav_header(hdr, hn, &wi) != ESP_OK || wi.bits_per_sample != 16) {
ESP_LOGW(TAG, "gb: bad/usupported WAV %s", path);
fclose(f);
continue;
}
fseek(f, wi.data_offset, SEEK_SET);
size_t remaining = wi.data_size;
while (remaining > 0 && !s_gb_stop && !s_gb_new) {
size_t want = remaining < sizeof(buf) ? remaining : sizeof(buf);
size_t got = fread(buf, 1, want, f);
if (got == 0) break;
size_t written = 0;
if (s_spk_handle_local) {
i2s_channel_write(s_spk_handle_local, buf, got, &written,
pdMS_TO_TICKS(500));
}
remaining -= got;
}
fclose(f);
}
}
void cmd_exec_play_file_async(const char *path)
{
if (!path || !path[0]) return;
strncpy(s_gb_path, path, sizeof(s_gb_path) - 1);
s_gb_path[sizeof(s_gb_path) - 1] = '\0';
s_gb_stop = true; // interrupt whatever is playing
s_gb_new = true; // … and pick up the new clip
if (!s_gb_task) {
xTaskCreate(gb_play_task, "gb_play", 4096, NULL, 4, &s_gb_task);
}
}
void cmd_exec_stop_play(void)
{
s_gb_stop = true;
s_gb_new = false;
}
// ─── op=play ──────────────────────────────────────────────────────────────────
static esp_err_t exec_play(const cJSON *a)
+8
View File
@@ -34,6 +34,14 @@ esp_err_t cmd_exec_handle(const char *data, size_t len);
// Must be called from main.c after speaker_init(), before any CMD arrives.
void cmd_exec_set_spk_handle(i2s_chan_handle_t h);
// Play a 16 kHz mono 16-bit WAV from the SD asynchronously, streamed in chunks
// (RAM-safe for long narration) and INTERRUPTIBLE: a new call stops the current
// clip and starts the new one. Used by the touch gamebook for passage audio.
void cmd_exec_play_file_async(const char *path);
// Stop the async narration (no-op if nothing is playing).
void cmd_exec_stop_play(void);
#ifdef __cplusplus
}
#endif
+234
View File
@@ -0,0 +1,234 @@
// gamebook.c — see gamebook.h. Touch CYOA for the ESP32-S3-BOX-3.
#include "gamebook.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cJSON.h"
#include "esp_log.h"
#include "esp_heap_caps.h"
#include "bsp/esp-bsp.h"
#include "lvgl.h"
#include "cmd_exec.h" // async WAV narration player
static const char *TAG = "gamebook";
#define GB_DIR "/sdcard/gamebook"
#define GB_LIBRARY GB_DIR "/library.json"
#define GB_JSON_MAX (256 * 1024) // expanded books are ~80 KB of JSON
// Colours (Workbench-ish palette).
#define COL_BG 0x101820
#define COL_TITLE 0xFFCC55
#define COL_TEXT 0xF0F0F0
#define COL_BTN 0x224466
static cJSON *s_lib_root = NULL; // owns library.json
static cJSON *s_lib = NULL; // borrowed: root->"library"
static int s_lib_n = 0;
static cJSON *s_book = NULL; // owns the current <id>.json
static cJSON *s_passages = NULL; // borrowed: book->"passages"
static lv_obj_t *s_root = NULL; // scrollable full-screen column
// ── PSRAM allocators for cJSON (book trees are large) ───────────────────────
static void *gb_malloc(size_t sz) { return heap_caps_malloc(sz, MALLOC_CAP_SPIRAM); }
static void gb_free(void *p) { heap_caps_free(p); }
static cJSON *load_json(const char *path)
{
FILE *f = fopen(path, "rb");
if (!f) { ESP_LOGW(TAG, "open %s failed", path); return NULL; }
fseek(f, 0, SEEK_END);
long sz = ftell(f);
rewind(f);
if (sz <= 0 || sz > GB_JSON_MAX) { fclose(f); ESP_LOGW(TAG, "%s size %ld", path, sz); return NULL; }
char *buf = heap_caps_malloc((size_t)sz + 1, MALLOC_CAP_SPIRAM);
if (!buf) { fclose(f); return NULL; }
size_t rd = fread(buf, 1, (size_t)sz, f);
fclose(f);
buf[rd] = '\0';
cJSON *root = cJSON_Parse(buf);
heap_caps_free(buf);
if (!root) ESP_LOGW(TAG, "malformed JSON: %s", path);
return root;
}
// ── Root container (created once, cleared per view) ─────────────────────────
// All UI functions below assume the LVGL lock is held (gamebook_init takes it;
// button callbacks already run inside the LVGL task / lock).
static void ensure_root(void)
{
if (s_root) { lv_obj_clean(s_root); return; }
lv_obj_t *scr = lv_screen_active();
s_root = lv_obj_create(scr);
lv_obj_set_size(s_root, LV_PCT(100), LV_PCT(100));
lv_obj_set_pos(s_root, 0, 0);
lv_obj_set_style_bg_color(s_root, lv_color_hex(COL_BG), 0);
lv_obj_set_style_bg_opa(s_root, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(s_root, 0, 0);
lv_obj_set_style_radius(s_root, 0, 0);
lv_obj_set_style_pad_all(s_root, 8, 0);
lv_obj_set_style_pad_row(s_root, 8, 0);
lv_obj_set_flex_flow(s_root, LV_FLEX_FLOW_COLUMN);
lv_obj_set_scroll_dir(s_root, LV_DIR_VER);
}
static lv_obj_t *add_title(const char *txt)
{
lv_obj_t *l = lv_label_create(s_root);
lv_obj_set_style_text_font(l, &lv_font_montserrat_24, 0);
lv_obj_set_style_text_color(l, lv_color_hex(COL_TITLE), 0);
lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP);
lv_obj_set_width(l, LV_PCT(100));
lv_label_set_text(l, txt);
return l;
}
static lv_obj_t *add_text(const char *txt)
{
lv_obj_t *l = lv_label_create(s_root);
lv_obj_set_style_text_font(l, &lv_font_montserrat_14, 0);
lv_obj_set_style_text_color(l, lv_color_hex(COL_TEXT), 0);
lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP);
lv_obj_set_width(l, LV_PCT(100));
lv_label_set_text(l, txt);
return l;
}
static lv_obj_t *add_button(const char *txt, lv_event_cb_t cb, void *user)
{
lv_obj_t *b = lv_button_create(s_root);
lv_obj_set_width(b, LV_PCT(100));
lv_obj_set_style_bg_color(b, lv_color_hex(COL_BTN), 0);
lv_obj_set_style_pad_all(b, 10, 0);
lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, user);
lv_obj_t *l = lv_label_create(b);
lv_obj_set_style_text_font(l, &lv_font_montserrat_14, 0);
lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP);
lv_obj_set_width(l, LV_PCT(100));
lv_label_set_text(l, txt);
return b;
}
// Forward decls
static void show_library(void);
static void enter_passage(const char *pid);
static void tile_cb(lv_event_t *e);
static void choice_cb(lv_event_t *e);
static void menu_cb(lv_event_t *e);
// ── Library menu ────────────────────────────────────────────────────────────
static void show_library(void)
{
cmd_exec_stop_play(); // silence any passage narration
if (s_book) { cJSON_Delete(s_book); s_book = NULL; s_passages = NULL; }
ensure_root();
add_title("Choisis ton histoire");
for (int i = 0; i < s_lib_n; i++) {
const cJSON *t = cJSON_GetObjectItem(cJSON_GetArrayItem(s_lib, i), "title");
add_button(cJSON_IsString(t) ? t->valuestring : "?",
tile_cb, (void *)(intptr_t)i);
}
lv_obj_scroll_to_y(s_root, 0, LV_ANIM_OFF);
}
// ── Passage ─────────────────────────────────────────────────────────────────
static void enter_passage(const char *pid)
{
const cJSON *p = cJSON_GetObjectItem(s_passages, pid);
if (!cJSON_IsObject(p)) { ESP_LOGW(TAG, "passage '%s' missing", pid); return; }
const cJSON *screen = cJSON_GetObjectItem(p, "screen");
const cJSON *text = cJSON_GetObjectItem(p, "text");
const cJSON *choices = cJSON_GetObjectItem(p, "choices");
ensure_root();
add_title(cJSON_IsString(screen) ? screen->valuestring : "");
add_text(cJSON_IsString(text) ? text->valuestring : "");
// Narration: play this passage's WAV from the SD if the pack has audio.
// Interruptible — tapping a choice cuts it and plays the next passage.
const cJSON *wav = cJSON_GetObjectItem(p, "wav");
if (cJSON_IsString(wav) && wav->valuestring[0]) {
char path[160];
snprintf(path, sizeof(path), "%s/%s", GB_DIR, wav->valuestring);
cmd_exec_play_file_async(path);
}
int n = cJSON_IsArray(choices) ? cJSON_GetArraySize(choices) : 0;
if (n == 0) {
add_button("Revenir au menu", menu_cb, NULL);
} else {
for (int i = 0; i < n; i++) {
const cJSON *c = cJSON_GetArrayItem(choices, i);
const cJSON *lbl = cJSON_GetObjectItem(c, "label");
const cJSON *g = cJSON_GetObjectItem(c, "goto");
// goto valuestring stays valid while s_book is alive → use as user_data
add_button(cJSON_IsString(lbl) ? lbl->valuestring : "?",
choice_cb, cJSON_IsString(g) ? g->valuestring : NULL);
}
}
lv_obj_scroll_to_y(s_root, 0, LV_ANIM_OFF);
ESP_LOGI(TAG, "passage '%s' (%d choices)", pid, n);
}
static void load_book(int idx)
{
const cJSON *e = cJSON_GetArrayItem(s_lib, idx);
const cJSON *file = cJSON_GetObjectItem(e, "book");
if (!cJSON_IsString(file)) return;
char path[128];
snprintf(path, sizeof(path), "%s/%s", GB_DIR, file->valuestring);
cJSON *book = load_json(path);
if (!book) return;
const cJSON *passages = cJSON_GetObjectItem(book, "passages");
const cJSON *start = cJSON_GetObjectItem(book, "start");
if (!cJSON_IsObject(passages) || !cJSON_IsString(start)) {
cJSON_Delete(book); return;
}
if (s_book) cJSON_Delete(s_book);
s_book = book;
s_passages = (cJSON *)passages;
ESP_LOGI(TAG, "load book #%d @ '%s'", idx, start->valuestring);
enter_passage(start->valuestring);
}
// ── Touch callbacks (run inside the LVGL task → lock already held) ───────────
static void tile_cb(lv_event_t *e) { load_book((int)(intptr_t)lv_event_get_user_data(e)); }
static void menu_cb(lv_event_t *e) { (void)e; show_library(); }
static void choice_cb(lv_event_t *e)
{
const char *goto_id = (const char *)lv_event_get_user_data(e);
if (goto_id) enter_passage(goto_id);
}
// ── Public init ─────────────────────────────────────────────────────────────
void gamebook_init(void)
{
cJSON_Hooks hooks = { .malloc_fn = gb_malloc, .free_fn = gb_free };
cJSON_InitHooks(&hooks); // keep the big book trees in PSRAM
if (bsp_sdcard_mount() != ESP_OK) {
ESP_LOGW(TAG, "no SD card — gamebook disabled");
return;
}
s_lib_root = load_json(GB_LIBRARY);
if (!s_lib_root) { ESP_LOGW(TAG, "no library.json — gamebook disabled"); return; }
s_lib = cJSON_GetObjectItem(s_lib_root, "library");
if (!cJSON_IsArray(s_lib) || cJSON_GetArraySize(s_lib) == 0) {
cJSON_Delete(s_lib_root); s_lib_root = NULL; s_lib = NULL;
ESP_LOGW(TAG, "empty library");
return;
}
s_lib_n = cJSON_GetArraySize(s_lib);
bsp_display_brightness_set(80);
if (bsp_display_lock(1000)) {
show_library();
bsp_display_unlock();
}
ESP_LOGI(TAG, "touch gamebook up (%d stories)", s_lib_n);
}
+27
View File
@@ -0,0 +1,27 @@
#pragma once
// gamebook — "livre dont tu es le héros" TACTILE pour l'ESP32-S3-BOX-3.
//
// Écran 320x240 + tactile : on lit le passage à l'écran et on TOUCHE un bouton
// pour choisir. Réutilise le pack gamebook du master sur la SD :
// /sdcard/gamebook/{library.json, <id>.json}
// où chaque passage porte {screen, text, choices:[{label, goto}]} (le champ
// "wav" éventuel est ignoré — version texte/tactile, pas d'audio requis).
//
// S'appuie sur la stack LVGL déjà démarrée par bsp_display_start() : on
// construit une UUI (menu d'histoires en liste, puis pages tactiles) et la
// navigation se fait dans les callbacks de boutons LVGL. 100% local, hors-ligne.
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
// Monte la SD, charge la bibliothèque et affiche le menu tactile des histoires.
// À appeler une fois depuis app_main, après bsp_display_start() et le serveur
// de fichiers (pour que la SD soit accessible). No-op si aucun pack présent.
void gamebook_init(void);
#ifdef __cplusplus
}
#endif
+6
View File
@@ -28,6 +28,7 @@
#include "scenario_server.h"
#include "plip_virtual.h"
#include "plip_ui.h"
#include "gamebook.h"
#include "stimulus.h"
#include "scenario_mesh.h"
#include "cmd_exec.h"
@@ -463,6 +464,11 @@ void app_main(void)
} else {
ESP_LOGW(TAG, "stimulus_init failed — QR/melody generator off");
}
/* Touch gamebook: if a story pack is on the SD (/sdcard/gamebook/),
* take over the screen with a tap-to-choose "livre dont tu es le
* héros". No-op (and the phone UI stays) when no pack is present. */
gamebook_init();
}
/* Start the ESP-NOW receiver so the master can relay scenarios to us even
+3 -2
View File
@@ -54,8 +54,9 @@
#define SPIFFS_LABEL "storage"
#define SPIFFS_BASE "/spiffs"
#define MAX_BODY (512 * 1024) /* streamed in 2 KB chunks → RAM-safe; raised
so full-length say() hint WAVs fit on SD */
#define MAX_BODY (3 * 1024 * 1024) /* streamed in 2 KB chunks → RAM-safe;
raised to fit full-length neural-TTS
(Kyutai) gamebook narration WAVs */
static volatile bool s_connected = false;
static httpd_handle_t s_httpd = NULL;