Compare commits

..

10 Commits

Author SHA1 Message Date
clement 43dc1478b1 box3: fix gamebook audio, accents, and crashes
CI / platformio (pull_request) Failing after 3m44s
CI / platformio (push) Failing after 3m34s
The BOX-3 touch gamebook had several blocking bugs that made the
voiced+foley story packs unusable. This fixes them end to end.

- Stack overflow on story start: gb_play_task kept a 2 KB buffer on
  its 4 KB stack and rebooted the board. Buffers are now static (the
  task is a singleton) and the stack is 6 KB.
- No sound at all: the ES8311 codec was never initialised — main.c
  wrote raw I2S to a muted DAC. Output (and the mic) now go through
  the BSP esp_codec_dev (bsp_audio_codec_speaker/microphone_init,
  esp_codec_dev_write/read). The power amplifier (GPIO46) is also
  forced on and volume set to 100, as the es8311 pa_pin handling did
  not drive it in practice.
- First passage silent: gb_play re-mounted the SD that the gamebook
  had already mounted; the failed second bsp_sdcard_mount tore down
  the VFS so the first fopen failed. Now we open the file first and
  only mount if that fails. A short silence lead-in also primes the
  codec/PA ramp.
- No accents: the gamebook used ASCII-only lv_font_montserrat_14/24.
  Added custom Montserrat fonts (font_fr_14/24, Latin-1 + œŒŸ « » ’ …,
  uncompressed so they render without LV_USE_FONT_COMPRESSED).
- Mic/voice streaming gated behind CONFIG_BOX3_VOICE_STREAMING
  (default off): as a gamebook the BOX-3 needs no mic, and the
  constant codec reads + bridge reconnects interfered with playback
  and flooded the log.

plip_virtual + cmd_exec handle types switched from i2s_chan_handle_t
to esp_codec_dev_handle_t accordingly.
2026-06-21 11:07:34 +02:00
clement b203f0e4de plip: optional auto story-ring (off by default)
CI / platformio (pull_request) Failing after 3m46s
CI / platformio (push) Failing after 4m1s
Escape-room mechanic: the retro phone can call the players on its own
and tell them a story when they pick up.

Approach: a story_ring_task rings at a random 15-30 min interval (only
when the line is idle and a story pack is on the SD). Picking up routes
through enter_story_from_ring(), which silences the bell and launches a
random story via plip_gamebook_begin_random() straight into the gamebook
state — no menu. If nobody answers within 1 min the bell stops and the
next ring is rescheduled; hanging up mid-ring also silences it.

The whole feature is gated behind CONFIG_PLIP_AUTO_RING (default n), so
the phone stays silent unless explicitly enabled in menuconfig. The hook
pickup paths (raw SLIC poll + debounced edge) mirror the existing NPC
incoming-call handling, which is unreliable while the bell rings.
2026-06-20 21:08:56 +02:00
clement 42b5d6375a plip: default speaker volume to maximum
CI / platformio (push) Failing after 6m1s
CI / platformio (pull_request) Failing after 25m39s
2026-06-20 09:31:05 +02:00
clement e37fa9ded2 gamebook: load expanded books from PSRAM 2026-06-20 09:31:05 +02:00
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
clement a8af29068b feat(gamebook): full-height scrolling text + real pad mapping
CI / platformio (pull_request) Failing after 4m50s
CI / platformio (push) Failing after 10m23s
Context:
Two issues on the Freenove gamebook: (1) long passages were clipped —
the reading band was small and text overflowed; (2) the 5-way pad was
decoded wrong, so navigation felt random, and on this unit the physical
Up button is electrically dead (held = no voltage change).

Approach:
Give the reading text the whole screen height in a vertically
scrollable container, split selection from scrolling, and remap the
keys to the pad's MEASURED voltages.

Changes:
- display_ui: gamebook view reworked — compact title, a full-height
  scrollable reading container (the body wraps and scrolls, never
  clipped), and a one-line choice selector pinned at the bottom; new
  display_ui_gamebook_scroll() + reset-on-new-passage; show() gains a
  reset_scroll arg; body buffer 512 -> 1100.
- gamebook.c: controls matched to the measured ladder
  (key2=LEFT, key4=DOWN, key5=RIGHT, key1=CLICK; key3 unused; Up dead):
  Left/Right scroll the text up/down, Down cycles the answer, Click
  validates; library nav cycles tiles without needing Up.

Impact:
Long passages are fully readable (they scroll), and all four working
pad buttons do the right thing; nothing depends on the dead Up button.
Hardware-calibrated against the real pad voltages.
2026-06-19 23:56:45 +02:00
clement c2527a0a66 feat(plip): audio gamebook on the phone (dial to choose)
CI / platformio (push) Has been cancelled
CI / platformio (pull_request) Failing after 5m42s
Context:
The Freenove plays a screen+buttons "livre dont vous etes le heros".
The PLIP retro phone has no screen, so we want a phone-native version:
an audio choose-your-own-adventure where the narrator reads the passage
and its numbered choices, and the player dials a digit to choose.

Approach:
A self-contained plip_gamebook module reads a phone audio pack from the
SD (narration WAVs already include the spoken "faites le N" prompts),
plays passages through the earpiece, and maps each dialed digit to a
choice. It hooks into the existing conversation state machine and the
existing dialer (rotary/DTMF) — fully offline, no model, no network.

Changes:
- New plip_gamebook.c/.h: loads /sdcard/gamebook/{library.json,
  menu.wav, <id>.json, <id>_<passage>.wav}; pickup plays the story
  menu, digit picks a story, then each passage plays its WAV and a
  dialed digit jumps to that choice; an ending + any digit returns to
  the menu. Interrupts narration so a dial is instant.
- conversation.c: new STATE_GAMEBOOK; off-hook from idle launches the
  gamebook when a pack is on the SD (else the normal dialtone/NPC flow,
  so the phone still works without a pack); each dialed digit is fed to
  the gamebook; go_idle() tears it down on hang-up.
- CMakeLists: build plip_gamebook.c.

Impact:
With a phone pack staged on its SD, the PLIP becomes an audio gamebook:
pick up, dial to choose your story and your path, hang up to stop.
Non-breaking when no pack is present. Builds clean.
2026-06-19 23:55:41 +02:00
clement 0027970907 fix(gamebook): scroll long passages + DELETE /game/file
CI / platformio (push) Failing after 5m21s
CI / platformio (pull_request) Failing after 5m25s
Context:
Two issues surfaced with the longer kid stories: (1) long passages were
clipped on screen — the body label wrapped in a fixed 196px band and
the tail was simply cut off; (2) old orphan WAVs (demo + archived
adventures) piled up on the SD with no way to remove them, since the
master had POST /game/file but no delete.

Approach:
Make the gamebook body label scroll vertically so the whole passage
goes by, and add a DELETE verb to /game/file mirroring the POST routing
so the host can clean stale assets off the SD.

Changes:
- display_ui: gamebook body label LV_LABEL_LONG_WRAP ->
  LV_LABEL_LONG_SCROLL_CIRCULAR; body buffer 512 -> 1100 so a full
  ~1000-char passage is held and scrolled, not truncated.
- game_endpoint: new DELETE /game/file?path=sd/<…>|apps/<…> (same
  whitelist/traversal guard as POST; unlink + 200/404).
- ota_server: max_uri_handlers 22 -> 24 for the extra verb.

Impact:
Long passages are fully readable on the LCD (they scroll), and the SD
gamebook folder can be cleaned remotely. Hardware-validated: 121 orphan
files deleted, library still opens 6 stories.
2026-06-19 16:30:11 +02:00
23 changed files with 7647 additions and 166 deletions
+2 -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" "font_fr_14.c" "font_fr_24.c"
INCLUDE_DIRS "."
PRIV_REQUIRES
driver
@@ -13,5 +13,6 @@ idf_component_register(
spiffs
scenario_mesh
espressif__esp-box-3
espressif__esp_codec_dev
lvgl__lvgl
)
+10
View File
@@ -1,5 +1,15 @@
menu "Zacus BOX-3 Voice Configuration"
config BOX3_VOICE_STREAMING
bool "Enable microphone + voice-bridge streaming"
default n
help
Start the mic capture task and the WebSocket voice-bridge client.
Off by default: as a touch gamebook the BOX-3 needs no microphone,
and the continuous codec reads + bridge reconnect attempts interfere
with speaker playback (the first narration clip came out silent) and
flood the log. Enable only when using the BOX-3 as a voice device.
config ZACUS_WIFI_SSID
string "WiFi SSID"
default "zacus-net"
+100 -15
View File
@@ -10,7 +10,7 @@
//
// Objective 2 — play: real WAV streaming to I2S
// 1. Mount SD card (best-effort, /sdcard). Parse WAV header (PCM-16).
// 2. Stream PCM samples via s_spk_handle (extern, same I2S channel as TTS).
// 2. Stream PCM samples via the ES8311 codec (esp_codec_dev), same path as TTS.
// 3. If SD absent / file not found → fallback bip + log.
// 4. Embedded test cue always available at virtual path "embedded://" or
// when the requested path starts with "embedded:" — proves WAV decode + I2S.
@@ -33,16 +33,16 @@
#include "embedded_wav.h" // s_embedded_wav[], EMBEDDED_WAV_SIZE
// audio_play_tone and s_spk_handle are defined in main.c
// audio_play_tone is defined in main.c (writes through the ES8311 codec).
extern void audio_play_tone(float frequency, int duration_ms);
// Speaker I2S handle — obtained from main.c via a weak accessor.
// We declare a weak symbol here; main.c must define cmd_exec_set_spk_handle()
// and call it after speaker_init(). Alternatively we use the driver directly.
#include "driver/i2s_std.h"
static i2s_chan_handle_t s_spk_handle_local = NULL;
// Speaker codec (ES8311) handle — obtained from main.c after speaker_init().
// Audio is streamed through esp_codec_dev so the ES8311 DAC is actually driven
// (a raw I2S write leaves the codec muted).
#include "esp_codec_dev.h"
static esp_codec_dev_handle_t s_spk_handle_local = NULL;
void cmd_exec_set_spk_handle(i2s_chan_handle_t h)
void cmd_exec_set_spk_handle(esp_codec_dev_handle_t h)
{
s_spk_handle_local = h;
}
@@ -344,13 +344,11 @@ static esp_err_t stream_pcm16(const uint8_t *pcm, size_t byte_len,
size_t offset = 0;
while (offset < byte_len) {
size_t chunk = (byte_len - offset < 1024) ? (byte_len - offset) : 1024;
size_t written = 0;
esp_err_t ret = i2s_channel_write(s_spk_handle_local,
pcm + offset, chunk,
&written, pdMS_TO_TICKS(500));
if (ret != ESP_OK) {
ESP_LOGW(TAG, "I2S write error: %s", esp_err_to_name(ret));
return ret;
int ret = esp_codec_dev_write(s_spk_handle_local,
(void *)(pcm + offset), (int)chunk);
if (ret != ESP_CODEC_DEV_OK) {
ESP_LOGW(TAG, "codec write error: %d", ret);
return ESP_FAIL;
}
offset += chunk;
}
@@ -488,6 +486,93 @@ 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;
/* The task is a singleton (guarded by s_gb_task), so these large buffers
* are kept static — on the stack they ate ~2.1 KB and, together with the
* FATFS fread internals + i2s + logging, overflowed the 4 KB stack and
* rebooted the board when a story started. */
static uint8_t hdr[64];
static 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;
/* The SD is normally already mounted (the gamebook mounts it at init).
* Try the file directly first — re-mounting an already-mounted card via
* bsp_sdcard_mount() FAILS and tears down the existing VFS, which made
* the very first narration clip silent. Only mount if the open fails. */
FILE *f = fopen(path, "rb");
if (!f) {
ensure_sd_mounted();
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);
/* Wake the codec/PA before the real audio: after idle the ES8311 mutes
* and ramps up on the first write, swallowing the start of the clip.
* A short silent lead-in lets the unmute settle so the narration plays
* from the very first word. */
memset(buf, 0, sizeof(buf));
for (int i = 0; i < 4 && s_spk_handle_local && !s_gb_stop && !s_gb_new; i++) {
esp_codec_dev_write(s_spk_handle_local, buf, (int)sizeof(buf));
}
size_t remaining = wi.data_size;
ESP_LOGI(TAG, "gb: play %s (%u B, %u Hz)", path,
(unsigned)wi.data_size, (unsigned)wi.sample_rate);
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;
if (s_spk_handle_local) {
esp_codec_dev_write(s_spk_handle_local, buf, (int)got);
}
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", 6144, 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)
+11 -3
View File
@@ -18,7 +18,7 @@
#include <stddef.h>
#include "esp_err.h"
#include "driver/i2s_std.h"
#include "esp_codec_dev.h"
#ifdef __cplusplus
extern "C" {
@@ -30,9 +30,17 @@ extern "C" {
// ESP_ERR_NOT_SUPPORTED for unknown ops (all logged, no crash).
esp_err_t cmd_exec_handle(const char *data, size_t len);
// Provide the speaker I2S channel handle so exec_play() can stream audio.
// Provide the speaker codec (ES8311) handle so exec_play() can stream audio.
// Must be called from main.c after speaker_init(), before any CMD arrives.
void cmd_exec_set_spk_handle(i2s_chan_handle_t h);
void cmd_exec_set_spk_handle(esp_codec_dev_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
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+238
View File
@@ -0,0 +1,238 @@
// 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";
// Custom fonts with French glyphs (ASCII + Latin-1 + + …). See main/font_fr_*.c.
LV_FONT_DECLARE(font_fr_14);
LV_FONT_DECLARE(font_fr_24);
#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, &font_fr_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, &font_fr_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, &font_fr_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
+94 -72
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"
@@ -39,8 +40,12 @@ static const char *TAG = "zacus-voice";
/* --------------- Shared state --------------- */
static i2s_chan_handle_t s_spk_handle = NULL; /* Speaker TX channel */
static i2s_chan_handle_t s_mic_handle = NULL; /* Microphone RX channel */
/* Audio now goes through the ES8311 (speaker) / ES7210 (mic) codecs via the
* BSP + esp_codec_dev. bsp_audio_init() owns the single shared I2S port; we
* must NOT create our own raw I2S channels (that left the ES8311 DAC unpowered
* and the speaker silent). */
static esp_codec_dev_handle_t s_spk_codec = NULL; /* ES8311 output (DAC) */
static esp_codec_dev_handle_t s_mic_codec = NULL; /* ES7210 input (ADC) */
static volatile bool s_wifi_connected = false;
static volatile bool s_voice_streaming = false;
@@ -113,11 +118,10 @@ static esp_err_t wifi_init_sta(void)
// generator can sequence a melody for the master's microphone.
void audio_play_tone(float frequency, int duration_ms)
{
if (!s_spk_handle || duration_ms <= 0) return;
if (!s_spk_codec || duration_ms <= 0) return;
const int total_samples = AUDIO_SAMPLE_RATE * duration_ms / 1000;
const float amplitude = 16000.0f;
int16_t buffer[256];
size_t bytes_written = 0;
int sample_idx = 0;
while (sample_idx < total_samples) {
int chunk = (total_samples - sample_idx < 256)
@@ -126,29 +130,27 @@ void audio_play_tone(float frequency, int duration_ms)
float t = (float)(sample_idx + i) / (float)AUDIO_SAMPLE_RATE;
buffer[i] = (int16_t)(amplitude * sinf(2.0f * M_PI * frequency * t));
}
i2s_channel_write(s_spk_handle, buffer, chunk * sizeof(int16_t),
&bytes_written, portMAX_DELAY);
esp_codec_dev_write(s_spk_codec, buffer, chunk * sizeof(int16_t));
sample_idx += chunk;
}
}
static void audio_test_tone(void)
{
if (!s_spk_handle) {
if (!s_spk_codec) {
ESP_LOGW(TAG, "Speaker not initialized, skipping test tone");
return;
}
ESP_LOGI(TAG, "Playing 440 Hz test tone (1 second)...");
/* Generate 1 second of 440 Hz sine wave using persistent speaker handle */
/* Generate 1 second of 440 Hz sine wave through the ES8311 codec */
const int duration_ms = 1000;
const int total_samples = AUDIO_SAMPLE_RATE * duration_ms / 1000;
const float frequency = 440.0f;
const float amplitude = 16000.0f;
int16_t buffer[256];
size_t bytes_written = 0;
int sample_idx = 0;
while (sample_idx < total_samples) {
@@ -157,7 +159,7 @@ static void audio_test_tone(void)
float t = (float)(sample_idx + i) / (float)AUDIO_SAMPLE_RATE;
buffer[i] = (int16_t)(amplitude * sinf(2.0f * M_PI * frequency * t));
}
i2s_channel_write(s_spk_handle, buffer, chunk * sizeof(int16_t), &bytes_written, portMAX_DELAY);
esp_codec_dev_write(s_spk_codec, buffer, chunk * sizeof(int16_t));
sample_idx += chunk;
}
@@ -173,15 +175,13 @@ static void tts_audio_callback(const uint8_t *data, size_t len)
* Called from WebSocket event context with incoming TTS PCM16 data.
* Write directly to the speaker I2S channel.
*/
if (!s_spk_handle || len == 0) {
if (!s_spk_codec || len == 0) {
return;
}
size_t bytes_written = 0;
esp_err_t ret = i2s_channel_write(s_spk_handle, data, len,
&bytes_written, pdMS_TO_TICKS(200));
if (ret != ESP_OK) {
ESP_LOGW(TAG, "TTS write to speaker failed: %s", esp_err_to_name(ret));
int ret = esp_codec_dev_write(s_spk_codec, (void *)data, (int)len);
if (ret != ESP_CODEC_DEV_OK) {
ESP_LOGW(TAG, "TTS write to speaker failed: %d", ret);
}
}
@@ -189,34 +189,42 @@ static void tts_audio_callback(const uint8_t *data, size_t len)
static esp_err_t speaker_init(void)
{
/* Enable power amplifier */
/* Init the ES8311 output codec via the BSP. This also runs bsp_audio_init()
* (shared duplex I2S port) + bsp_i2c_init() and configures the power
* amplifier (BSP_POWER_AMP_IO = GPIO46). The ES8311 DAC is configured over
* I2C here — without this the speaker stays muted no matter what we push on
* I2S. */
s_spk_codec = bsp_audio_codec_speaker_init();
if (!s_spk_codec) {
ESP_LOGE(TAG, "bsp_audio_codec_speaker_init failed — speaker unavailable");
return ESP_FAIL;
}
/* All speaker audio in this app is 16 kHz / 16-bit / mono PCM. */
esp_codec_dev_sample_info_t fs = {
.bits_per_sample = AUDIO_BITS,
.channel = AUDIO_CHANNELS,
.channel_mask = 0,
.sample_rate = AUDIO_SAMPLE_RATE,
.mclk_multiple = 0,
};
int ret = esp_codec_dev_open(s_spk_codec, &fs);
if (ret != ESP_CODEC_DEV_OK) {
ESP_LOGE(TAG, "esp_codec_dev_open (speaker) failed: %d", ret);
return ESP_FAIL;
}
/* Output volume 0..100. */
esp_codec_dev_set_out_vol(s_spk_codec, 100);
/* Belt-and-suspenders: the es8311 driver is configured with pa_pin=GPIO46
* (pa_reverted=false) and should raise it on open, but the speaker stays
* silent in practice — force the power amplifier on explicitly. */
gpio_set_direction(BOX3_PA_ENABLE, GPIO_MODE_OUTPUT);
gpio_set_level(BOX3_PA_ENABLE, 1);
i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER);
ESP_ERROR_CHECK(i2s_new_channel(&chan_cfg, &s_spk_handle, NULL));
i2s_std_config_t std_cfg = {
.clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(AUDIO_SAMPLE_RATE),
.slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO),
.gpio_cfg = {
.mclk = BOX3_I2S_MCLK,
.bclk = BOX3_I2S_BCLK,
.ws = BOX3_I2S_WS,
.dout = BOX3_I2S_DOUT,
.din = I2S_GPIO_UNUSED,
.invert_flags = {
.mclk_inv = false,
.bclk_inv = false,
.ws_inv = false,
},
},
};
ESP_ERROR_CHECK(i2s_channel_init_std_mode(s_spk_handle, &std_cfg));
ESP_ERROR_CHECK(i2s_channel_enable(s_spk_handle));
ESP_LOGI(TAG, "Speaker I2S initialized (I2S_NUM_0)");
ESP_LOGI(TAG, "Speaker codec (ES8311) initialized @ %d Hz, vol=100, PA on",
AUDIO_SAMPLE_RATE);
return ESP_OK;
}
@@ -226,43 +234,45 @@ static void mic_monitor_task(void *arg)
{
ESP_LOGI(TAG, "Starting mic capture task...");
i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_1, I2S_ROLE_MASTER);
ESP_ERROR_CHECK(i2s_new_channel(&chan_cfg, NULL, &s_mic_handle));
/* Init the ES7210 input codec via the BSP. It shares the same I2S port
* (already created by speaker_init -> bsp_audio_init); creating a separate
* raw I2S channel here would conflict with that shared port. */
s_mic_codec = bsp_audio_codec_microphone_init();
if (!s_mic_codec) {
ESP_LOGE(TAG, "bsp_audio_codec_microphone_init failed — mic unavailable");
vTaskDelete(NULL);
return;
}
i2s_std_config_t std_cfg = {
.clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(AUDIO_SAMPLE_RATE),
.slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO),
.gpio_cfg = {
.mclk = I2S_GPIO_UNUSED,
.bclk = BOX3_I2S_BCLK,
.ws = BOX3_I2S_WS,
.dout = I2S_GPIO_UNUSED,
.din = BOX3_I2S_DIN,
.invert_flags = {
.mclk_inv = false,
.bclk_inv = false,
.ws_inv = false,
},
},
esp_codec_dev_sample_info_t fs = {
.bits_per_sample = AUDIO_BITS,
.channel = AUDIO_CHANNELS,
.channel_mask = 0,
.sample_rate = AUDIO_SAMPLE_RATE,
.mclk_multiple = 0,
};
ESP_ERROR_CHECK(i2s_channel_init_std_mode(s_mic_handle, &std_cfg));
ESP_ERROR_CHECK(i2s_channel_enable(s_mic_handle));
int oret = esp_codec_dev_open(s_mic_codec, &fs);
if (oret != ESP_CODEC_DEV_OK) {
ESP_LOGE(TAG, "esp_codec_dev_open (mic) failed: %d", oret);
vTaskDelete(NULL);
return;
}
/* ES7210 input gain (dB). 30 dB is a sane default for the BOX-3 mic array. */
esp_codec_dev_set_in_gain(s_mic_codec, 30.0f);
int16_t buffer[AUDIO_FRAME_SAMPLES];
size_t bytes_read = 0;
int rms_log_counter = 0;
while (1) {
esp_err_t ret = i2s_channel_read(s_mic_handle, buffer,
AUDIO_FRAME_SAMPLES * sizeof(int16_t),
&bytes_read, pdMS_TO_TICKS(1000));
if (ret != ESP_OK) {
ESP_LOGW(TAG, "Mic read error: %s", esp_err_to_name(ret));
int ret = esp_codec_dev_read(s_mic_codec, buffer,
AUDIO_FRAME_SAMPLES * sizeof(int16_t));
if (ret != ESP_CODEC_DEV_OK) {
ESP_LOGW(TAG, "Mic read error: %d", ret);
vTaskDelay(pdMS_TO_TICKS(20));
continue;
}
int samples = bytes_read / sizeof(int16_t);
int samples = AUDIO_FRAME_SAMPLES;
/* If voice streaming is active, send PCM to bridge */
if (s_voice_streaming) {
@@ -422,14 +432,19 @@ void app_main(void)
/* Initialize persistent speaker output (used for test tone + TTS playback) */
speaker_init();
/* Pass speaker handle to CMD executor for real WAV playback */
cmd_exec_set_spk_handle(s_spk_handle);
/* Pass speaker codec handle to CMD executor for real WAV playback */
cmd_exec_set_spk_handle(s_spk_codec);
/* Play test tone to verify audio output */
audio_test_tone();
/* Start mic capture task (reads I2S mic, streams to bridge when active) */
#if CONFIG_BOX3_VOICE_STREAMING
/* Start mic capture task (reads I2S mic, streams to bridge when active).
* Off by default: as a touch gamebook the BOX-3 needs no mic, and the
* continuous codec reads + bridge reconnects interfered with the speaker
* (the first narration clip came out silent) and spammed the log. */
xTaskCreatePinnedToCore(mic_monitor_task, "mic_capture", 4096, NULL, 5, NULL, 1);
#endif
/* Start button handler (BOOT button toggles voice streaming) */
xTaskCreate(button_task, "button", 2048, NULL, 4, NULL);
@@ -438,8 +453,10 @@ void app_main(void)
ESP_LOGI(TAG, "Connecting to WiFi...");
wifi_init_sta();
#if CONFIG_BOX3_VOICE_STREAMING
/* Start voice bridge connection task (waits for WiFi, then connects WS) */
xTaskCreate(voice_bridge_task, "voice_bridge", 6144, NULL, 5, NULL);
#endif
/* Start the scenario hot-load HTTP server (POST /game/scenario).
* httpd_start binds to all netifs — works as soon as the WiFi STA has an IP. */
@@ -449,7 +466,7 @@ void app_main(void)
/* Phone-less PLIP annex: same REST contract as PLIP_FIRMWARE
* (POST /ring /stop /play, GET /status), ring on the speaker,
* BOOT button as the virtual hook switch. */
if (plip_virtual_init(scenario_server_handle(), s_spk_handle) != ESP_OK) {
if (plip_virtual_init(scenario_server_handle(), s_spk_codec) != ESP_OK) {
ESP_LOGW(TAG, "plip_virtual_init failed — virtual phone unavailable");
} else if (plip_ui_init() != ESP_OK) {
/* REST/ESP-NOW phone still works headless if the UI fails. */
@@ -463,6 +480,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
+5 -5
View File
@@ -38,7 +38,7 @@ typedef struct {
char reason[32]; /* "pickup" | "hangup" | "ring_timeout" | "dial:<n>" */
} hook_event_t;
static i2s_chan_handle_t s_spk;
static esp_codec_dev_handle_t s_spk;
static volatile plip_hook_state_t s_state = PLIP_HOOK_ON;
static volatile bool s_ring_stop = false;
static TaskHandle_t s_ring_task = NULL;
@@ -107,7 +107,6 @@ static void ring_burst(int duration_ms)
{
const int total = AUDIO_SAMPLE_RATE * duration_ms / 1000;
int16_t buffer[256];
size_t written = 0;
int idx = 0;
while (idx < total && !s_ring_stop) {
int chunk = (total - idx < 256) ? (total - idx) : 256;
@@ -116,8 +115,9 @@ static void ring_burst(int duration_ms)
buffer[i] = (int16_t) (RING_AMPLITUDE *
sinf(2.0f * (float) M_PI * RING_FREQ_HZ * t));
}
i2s_channel_write(s_spk, buffer, chunk * sizeof(int16_t), &written,
pdMS_TO_TICKS(500));
if (s_spk) {
esp_codec_dev_write(s_spk, buffer, chunk * sizeof(int16_t));
}
idx += chunk;
}
}
@@ -279,7 +279,7 @@ plip_hook_state_t plip_virtual_state(void)
return s_state;
}
esp_err_t plip_virtual_init(httpd_handle_t server, i2s_chan_handle_t spk)
esp_err_t plip_virtual_init(httpd_handle_t server, esp_codec_dev_handle_t spk)
{
if (!server || !spk) return ESP_ERR_INVALID_ARG;
s_spk = spk;
+2 -2
View File
@@ -16,7 +16,7 @@
#pragma once
#include <stdbool.h>
#include "driver/i2s_std.h"
#include "esp_codec_dev.h"
#include "esp_err.h"
#include "esp_http_server.h"
@@ -34,7 +34,7 @@ typedef enum {
// Register the REST handlers on `server` and keep `spk` for the ring tone.
// Call once after scenario_server_start(); the speaker channel must be
// enabled (speaker_init() in main.c).
esp_err_t plip_virtual_init(httpd_handle_t server, i2s_chan_handle_t spk);
esp_err_t plip_virtual_init(httpd_handle_t server, esp_codec_dev_handle_t spk);
// Forward a BOOT-button press. Returns true when the press was consumed as
// a hook transition (pickup/hangup) — the caller should then skip its own
+60 -25
View File
@@ -255,9 +255,12 @@ static lv_obj_t *s_gb_title; // page title, top
static lv_obj_t *s_gb_body; // full passage text, wrapped
static lv_obj_t *s_gb_menu; // choice lines, bottom
static volatile bool s_gamebook_open = false;
static lv_obj_t *s_gb_body_cont; // scrollable container holding s_gb_body
static char s_gb_title_buf[64];
static char s_gb_body_buf[512];
static char s_gb_body_buf[1100];
static char s_gb_menu_buf[160];
static volatile int s_gb_scroll_req = 0; // pending scroll: +pages down / -pages up
static volatile bool s_gb_scroll_home = false; // reset scroll to top on new passage
// ── Gamebook library (tile grid) ────────────────────────────────────────────
#define LIB_TILES 6
@@ -824,37 +827,49 @@ static void build_gamebook_screen(void) {
lv_obj_set_style_bg_color(s_scr_gamebook, lv_color_hex(0x000000), 0);
lv_obj_set_style_bg_opa(s_scr_gamebook, LV_OPA_COVER, 0);
// Compact title line at the very top.
s_gb_title = lv_label_create(s_scr_gamebook);
lv_obj_set_style_text_color(s_gb_title, lv_color_hex(COL_VALUE), 0);
lv_obj_set_style_text_font(s_gb_title, &lv_font_montserrat_24, 0);
lv_obj_set_style_text_color(s_gb_title, lv_color_hex(COL_CODE), 0);
lv_obj_set_style_text_font(s_gb_title, &lv_font_montserrat_14, 0);
lv_label_set_long_mode(s_gb_title, LV_LABEL_LONG_DOT);
lv_obj_set_width(s_gb_title, DUI_HOR_RES - 16);
lv_obj_set_width(s_gb_title, DUI_HOR_RES - 12);
lv_obj_set_style_text_align(s_gb_title, LV_TEXT_ALIGN_CENTER, 0);
lv_label_set_text(s_gb_title, "");
lv_obj_align(s_gb_title, LV_ALIGN_TOP_MID, 0, 6);
lv_obj_align(s_gb_title, LV_ALIGN_TOP_MID, 0, 4);
// Body band: top area, height-bounded so it can never run into the menu.
s_gb_body = lv_label_create(s_scr_gamebook);
// Choice selector: a single line pinned at the very bottom. Left/Right cycle
// the selection, click confirms. Drawn last (foreground).
s_gb_menu = lv_label_create(s_scr_gamebook);
lv_obj_set_style_text_color(s_gb_menu, lv_color_hex(COL_CODE), 0);
lv_obj_set_style_bg_color(s_gb_menu, lv_color_hex(0x182230), 0);
lv_obj_set_style_bg_opa(s_gb_menu, LV_OPA_COVER, 0);
lv_obj_set_style_pad_all(s_gb_menu, 5, 0);
lv_obj_set_style_text_font(s_gb_menu, &lv_font_montserrat_14, 0);
lv_label_set_long_mode(s_gb_menu, LV_LABEL_LONG_DOT);
lv_obj_set_width(s_gb_menu, DUI_HOR_RES);
lv_obj_set_style_text_align(s_gb_menu, LV_TEXT_ALIGN_CENTER, 0);
lv_label_set_text(s_gb_menu, "");
lv_obj_align(s_gb_menu, LV_ALIGN_BOTTOM_MID, 0, 0);
// Reading area: a vertically-scrollable container filling the whole height
// between the title and the choice line. Up/Down scroll it by half-pages.
s_gb_body_cont = lv_obj_create(s_scr_gamebook);
lv_obj_set_style_bg_color(s_gb_body_cont, lv_color_hex(0x000000), 0);
lv_obj_set_style_bg_opa(s_gb_body_cont, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(s_gb_body_cont, 0, 0);
lv_obj_set_style_pad_all(s_gb_body_cont, 6, 0);
lv_obj_set_size(s_gb_body_cont, DUI_HOR_RES, 264); // 24 (title) .. 288 (menu)
lv_obj_set_pos(s_gb_body_cont, 0, 24);
lv_obj_set_scroll_dir(s_gb_body_cont, LV_DIR_VER);
lv_obj_set_scrollbar_mode(s_gb_body_cont, LV_SCROLLBAR_MODE_AUTO);
s_gb_body = lv_label_create(s_gb_body_cont);
lv_obj_set_style_text_color(s_gb_body, lv_color_hex(COL_VALUE), 0);
lv_obj_set_style_text_font(s_gb_body, &lv_font_montserrat_14, 0);
lv_label_set_long_mode(s_gb_body, LV_LABEL_LONG_WRAP);
lv_obj_set_size(s_gb_body, DUI_HOR_RES - 24, 196);
lv_obj_set_width(s_gb_body, DUI_HOR_RES - 24); // container width minus padding
lv_label_set_text(s_gb_body, "");
lv_obj_align(s_gb_body, LV_ALIGN_TOP_LEFT, 12, 44);
// Menu band: reserved strip at the bottom, drawn last (foreground), with a
// faint background so the choices stand out from the body text.
s_gb_menu = lv_label_create(s_scr_gamebook);
lv_obj_set_style_text_color(s_gb_menu, lv_color_hex(COL_CODE), 0);
lv_obj_set_style_bg_color(s_gb_menu, lv_color_hex(0x101820), 0);
lv_obj_set_style_bg_opa(s_gb_menu, LV_OPA_COVER, 0);
lv_obj_set_style_pad_all(s_gb_menu, 6, 0);
lv_obj_set_style_text_font(s_gb_menu, &lv_font_montserrat_14, 0);
lv_label_set_long_mode(s_gb_menu, LV_LABEL_LONG_WRAP);
lv_obj_set_width(s_gb_menu, DUI_HOR_RES);
lv_label_set_text(s_gb_menu, "");
lv_obj_align(s_gb_menu, LV_ALIGN_BOTTOM_LEFT, 0, 0);
lv_obj_move_foreground(s_gb_menu);
lv_obj_align(s_gb_body, LV_ALIGN_TOP_LEFT, 0, 0);
}
static void build_status_screen(void) {
@@ -949,11 +964,22 @@ static void apply_status(const display_status_t *s) {
// Active step → scene view; idle → status view. The 5-way buttons can
// override (1=force status, 2=force scene; 0=auto). Fade 240 ms, the
// original ui_manager default transition (SceneTransition::kFade, 240).
// Gamebook page: refresh its labels from the latest show() buffers.
// Gamebook page: refresh its labels from the latest show() buffers, then
// apply any pending manual scroll (Up/Down) on the reading container.
if (s_gamebook_open) {
lv_label_set_text(s_gb_title, s_gb_title_buf);
lv_label_set_text(s_gb_body, s_gb_body_buf);
lv_label_set_text(s_gb_menu, s_gb_menu_buf);
if (s_gb_scroll_home) {
lv_obj_scroll_to_y(s_gb_body_cont, 0, LV_ANIM_OFF);
s_gb_scroll_home = false;
}
int req = s_gb_scroll_req;
if (req != 0) {
s_gb_scroll_req = 0;
// +1 page = read further down (content moves up): negative dy.
lv_obj_scroll_by(s_gb_body_cont, 0, -req * 130, LV_ANIM_ON);
}
}
// Library grid: refresh tile titles + highlight the selected one.
if (s_library_open) {
@@ -1278,18 +1304,27 @@ extern "C" void display_ui_set_key_hook(display_ui_key_hook_t hook) {
}
extern "C" void display_ui_gamebook_show(const char *title, const char *body,
const char *menu) {
const char *menu, bool reset_scroll) {
if (s_mutex) xSemaphoreTake(s_mutex, portMAX_DELAY);
snprintf(s_gb_title_buf, sizeof(s_gb_title_buf), "%s", title ? title : "");
snprintf(s_gb_body_buf, sizeof(s_gb_body_buf), "%s", body ? body : "");
snprintf(s_gb_menu_buf, sizeof(s_gb_menu_buf), "%s", menu ? menu : "");
if (reset_scroll) s_gb_scroll_home = true; // new passage → back to top
s_gamebook_open = true;
s_dirty = true;
if (s_mutex) xSemaphoreGive(s_mutex);
}
extern "C" void display_ui_gamebook_scroll(int dir) {
// dir > 0 : read further down ; dir < 0 : back up. Accumulated and applied
// on the display task (this runs on the buttons task).
s_gb_scroll_req += (dir > 0) ? 1 : -1;
s_dirty = true;
}
extern "C" void display_ui_gamebook_hide(void) {
s_gamebook_open = false;
s_gb_scroll_req = 0;
s_dirty = true;
}
@@ -103,7 +103,13 @@ void display_ui_set_key_hook(display_ui_key_hook_t hook);
* @param menu choice lines (bottom), e.g. "[OK] ...\n[<>] ..."
*/
void display_ui_gamebook_show(const char *title, const char *body,
const char *menu);
const char *menu, bool reset_scroll);
/**
* @brief Scroll the gamebook reading area (Up/Down buttons).
* @param dir >0 = read further down, <0 = back up. Half-page per call.
*/
void display_ui_gamebook_scroll(int dir);
/** @brief Leave the gamebook view and return to the normal scene/status flow. */
void display_ui_gamebook_hide(void);
@@ -1173,6 +1173,43 @@ static esp_err_t handle_file_post(httpd_req_t *req) {
return send_json(req, "200 OK", resp);
}
// ─── DELETE /game/file?path=sd/<…>|apps/<…> — remove a staged file ───────────
// Same path routing/whitelist as the POST handler. Lets the host clean stale
// assets (e.g. orphan gamebook WAVs) off the SD without reflashing.
static esp_err_t handle_file_delete(httpd_req_t *req) {
char query[160], path_param[96];
if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK ||
httpd_query_key_value(query, "path", path_param,
sizeof(path_param)) != ESP_OK) {
return send_error(req, "400 Bad Request", "missing path param");
}
const bool to_sd = (strncmp(path_param, "sd/", 3) == 0);
const bool to_apps = (strncmp(path_param, "apps/", 5) == 0);
if ((!to_sd && !to_apps) || strstr(path_param, "..") ||
path_param[strlen(path_param) - 1] == '/') {
return send_error(req, "403 Forbidden", "path must be under apps/ or sd/");
}
char full[192];
if (to_sd) {
if (!sd_storage_ready()) {
return send_error(req, "503 Service Unavailable", "sd_not_mounted");
}
snprintf(full, sizeof(full), "/sdcard/%s", path_param + 3);
} else {
if (mount_storage_lazy() != ESP_OK) {
return send_error(req, "503 Service Unavailable", "storage_unavailable");
}
snprintf(full, sizeof(full), "/littlefs/%s", path_param);
}
if (unlink(full) != 0) {
return send_error(req, "404 Not Found", "remove failed");
}
ESP_LOGI(TAG, "file removed: %s", full);
char resp[192];
snprintf(resp, sizeof(resp), "{\"status\":\"ok\",\"removed\":\"%s\"}", path_param);
return send_json(req, "200 OK", resp);
}
void game_endpoint_get_scene(scene_binding_t *out) {
if (!out) return;
// Lock-free snapshot, same class as get_puzzle_status: written on the
@@ -1244,6 +1281,12 @@ esp_err_t game_endpoint_init(httpd_handle_t server) {
.handler = handle_file_post,
.user_ctx = NULL,
};
static const httpd_uri_t uri_file_delete = {
.uri = "/game/file",
.method = HTTP_DELETE,
.handler = handle_file_delete,
.user_ctx = NULL,
};
static const httpd_uri_t uri_espnow_cmd_post = {
.uri = "/game/espnow/cmd",
.method = HTTP_POST,
@@ -1326,6 +1369,10 @@ esp_err_t game_endpoint_init(httpd_handle_t server) {
if (err != ESP_OK) {
ESP_LOGW(TAG, "register POST /game/step: %s", esp_err_to_name(err));
}
err = httpd_register_uri_handler(server, &uri_file_delete);
if (err != ESP_OK) {
ESP_LOGW(TAG, "register DELETE /game/file: %s", esp_err_to_name(err));
}
err = httpd_register_uri_handler(server, &uri_file_post);
if (err != ESP_OK) {
ESP_LOGW(TAG, "register /game/file: %s", esp_err_to_name(err));
+68 -36
View File
@@ -9,6 +9,7 @@
#include "cJSON.h"
#include "esp_log.h"
#include "esp_heap_caps.h"
#include "display_ui.h"
#include "media_manager.h"
@@ -17,7 +18,7 @@ static const char *TAG = "gamebook";
#define GAMEBOOK_DIR "/sdcard/gamebook"
#define LIBRARY_JSON GAMEBOOK_DIR "/library.json"
#define JSON_MAX (64 * 1024) /* per-file JSON sanity cap */
#define JSON_MAX (256 * 1024) /* expanded books are ~80 KB of JSON */
#define LIB_MAX 6 /* matches the display tile grid */
typedef enum { GB_OFF, GB_LIBRARY, GB_STORY } gb_mode_t;
@@ -39,6 +40,10 @@ static volatile gb_mode_t s_mode = GB_OFF;
bool gamebook_active(void) { return s_mode != GB_OFF; }
/* PSRAM allocators for cJSON — book trees are large (see gamebook_init). */
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); }
/* Read a whole JSON file from the SD into a cJSON tree (caller frees). */
static cJSON *load_json(const char *path)
{
@@ -48,13 +53,13 @@ static cJSON *load_json(const char *path)
long sz = ftell(f);
rewind(f);
if (sz <= 0 || sz > JSON_MAX) { fclose(f); ESP_LOGW(TAG, "%s size %ld", path, sz); return NULL; }
char *buf = malloc((size_t)sz + 1);
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);
free(buf);
heap_caps_free(buf);
if (!root) ESP_LOGW(TAG, "malformed JSON: %s", path);
return root;
}
@@ -112,8 +117,10 @@ static int cur_choice_count(void)
return cJSON_IsArray(ch) ? cJSON_GetArraySize(ch) : 0;
}
/* (Re)draw the current page: title + wrapped text + choice list with a cursor. */
static void render_page(void)
/* (Re)draw the current page. `home` true = new passage (reset scroll to top);
* false = same passage, only the choice cursor moved (keep scroll position).
* The bottom line shows ONE choice with < > arrows (Left/Right cycles). */
static void render_page(bool home)
{
const cJSON *p = cur_passage();
if (!cJSON_IsObject(p)) return;
@@ -122,23 +129,25 @@ static void render_page(void)
const cJSON *choices = cJSON_GetObjectItem(p, "choices");
int n = cJSON_IsArray(choices) ? cJSON_GetArraySize(choices) : 0;
char menu[256];
char menu[160];
if (n == 0) {
snprintf(menu, sizeof(menu), "~ Fin ~ (clic = bibliotheque)");
} else {
size_t off = 0;
for (int i = 0; i < n && off < sizeof(menu); i++) {
const cJSON *lbl = cJSON_GetObjectItem(cJSON_GetArrayItem(choices, i),
"label");
off += snprintf(menu + off, sizeof(menu) - off, "%s%s\n",
i == s_sel ? "> " : " ",
cJSON_IsString(lbl) ? lbl->valuestring : "?");
const cJSON *lbl = cJSON_GetObjectItem(cJSON_GetArrayItem(choices, s_sel),
"label");
const char *txt = cJSON_IsString(lbl) ? lbl->valuestring : "?";
if (n > 1) {
/* Down cycles the answer, Click validates. */
snprintf(menu, sizeof(menu), "%d/%d %s (bas / clic)",
s_sel + 1, n, txt);
} else {
snprintf(menu, sizeof(menu), "%s (clic)", txt);
}
}
display_ui_gamebook_show(
cJSON_IsString(screen) ? screen->valuestring : s_title,
cJSON_IsString(text) ? text->valuestring : "",
menu);
menu, home);
}
static void enter_passage(const char *pid)
@@ -147,7 +156,7 @@ static void enter_passage(const char *pid)
if (!cJSON_IsObject(p)) { ESP_LOGW(TAG, "passage '%s' not found", pid); return; }
snprintf(s_current, sizeof(s_current), "%s", pid);
s_sel = 0;
render_page();
render_page(true);
const cJSON *wav = cJSON_GetObjectItem(p, "wav");
if (cJSON_IsString(wav) && wav->valuestring[0]) {
@@ -191,43 +200,58 @@ static void load_book(int idx)
// ── Input ─────────────────────────────────────────────────────────────────────
/* Library grid nav: 2 columns. up/down = ±2 (row), left/right = ±1 (col),
* click = open the highlighted story. Codes: 1=click 2=down 3=left 4=right
* 5=up (5-way ladder). */
/* MEASURED pad mapping on this hardware (ADC ladder, see calibration):
* key1 = CLICK (centre, ~0 mV)
* key2 = LEFT (gauche, ~700 mV)
* key4 = DOWN (bas, ~1350 mV)
* key5 = RIGHT (droite, ~1994 mV)
* key3 is never produced; the physical UP button is electrically dead
* (held = no voltage change), so nothing can depend on it. */
#define PAD_CLICK 1
#define PAD_LEFT 2
#define PAD_DOWN 4
#define PAD_RIGHT 5
/* Library nav: Down/Right → next tile, Left → previous, Click → open.
* Selection wraps, so the working buttons reach every story without Up. */
static bool library_key(uint8_t key)
{
int s = s_lib_sel;
if (s_lib_n <= 0) return true;
switch (key) {
case 5: s -= 2; break; /* up */
case 2: s += 2; break; /* down */
case 3: s -= 1; break; /* left */
case 4: s += 1; break; /* right */
case 1: /* click → open */
load_book(s_lib_sel);
return true;
default: return true;
case PAD_DOWN: case PAD_RIGHT:
s_lib_sel = (s_lib_sel + 1) % s_lib_n; render_library(); break;
case PAD_LEFT:
s_lib_sel = (s_lib_sel - 1 + s_lib_n) % s_lib_n; render_library(); break;
case PAD_CLICK:
load_book(s_lib_sel); break;
default: break;
}
if (s >= 0 && s < s_lib_n) { s_lib_sel = s; render_library(); }
return true;
}
/* Story nav: D-pad moves the choice cursor, click confirms. Ending + click
* returns to the library. */
/* Story nav (Up is dead, so it's never used):
* Left → scroll the reading text UP
* Right → scroll the reading text DOWN
* Down → next answer (cycles through all choices, wraps around)
* Click → validate the highlighted answer
* On an ending page (no choices): Left/Right still scroll, Click → library. */
static bool story_key(uint8_t key)
{
if (key == PAD_LEFT) { display_ui_gamebook_scroll(-1); return true; } /* scroll up */
if (key == PAD_RIGHT) { display_ui_gamebook_scroll(+1); return true; } /* scroll down */
int n = cur_choice_count();
if (n == 0) { /* ending → back to library on click */
if (key == 1) open_library();
if (n == 0) { /* ending → click returns to library */
if (key == PAD_CLICK) open_library();
return true;
}
switch (key) {
case 5: case 3: s_sel = (s_sel - 1 + n) % n; render_page(); break; /* up/left */
case 2: case 4: s_sel = (s_sel + 1) % n; render_page(); break; /* down/right */
case 1: { /* click → confirm */
case PAD_DOWN: s_sel = (s_sel + 1) % n; render_page(false); break; /* next answer */
case PAD_CLICK: { /* validate */
const cJSON *choices = cJSON_GetObjectItem(cur_passage(), "choices");
const cJSON *g = cJSON_GetObjectItem(cJSON_GetArrayItem(choices, s_sel), "goto");
if (cJSON_IsString(g)) {
ESP_LOGI(TAG, "select #%d -> '%s'", s_sel, g->valuestring);
ESP_LOGI(TAG, "validate #%d -> '%s'", s_sel, g->valuestring);
enter_passage(g->valuestring);
}
break;
@@ -266,6 +290,14 @@ void gamebook_stop(void)
void gamebook_init(void)
{
/* Keep the large book trees in PSRAM — an expanded book is ~80 KB of JSON,
* whose parsed node tree would exhaust the internal heap on this firmware. */
static cJSON_Hooks hooks = {
.malloc_fn = gb_malloc,
.free_fn = gb_free,
};
cJSON_InitHooks(&hooks);
/* Only register the pad hook here — the SD and media_manager aren't up yet
* at this point in boot. main.c calls gamebook_start() once they are, to
* boot into the library. */
+3 -3
View File
@@ -310,9 +310,9 @@ esp_err_t ota_server_init(void) {
// HTTP server config
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.server_port = OTA_SERVER_PORT;
config.max_uri_handlers = 22; // ota + voice_hook + game (incl. /game/step,
// /game/puzzle_state, /game/file, relay,
// /game/gamebook)
config.max_uri_handlers = 24; // ota + voice_hook + game (incl. /game/step,
// /game/puzzle_state, /game/file POST+DELETE,
// relay, /game/gamebook)
// + headroom
config.uri_match_fn = httpd_uri_match_wildcard;
config.stack_size = 8192;
+1
View File
@@ -13,6 +13,7 @@ idf_component_register(
"dtmf.c"
"turn_client.c"
"slic.c"
"plip_gamebook.c"
INCLUDE_DIRS "."
PRIV_REQUIRES
driver
+10
View File
@@ -84,6 +84,16 @@ menu "PLIP Voice Configuration"
Can be combined with PLIP_DIAL_PULSE: whichever source detects a
digit first wins. Default off — enable for touch-tone handsets.
config PLIP_AUTO_RING
bool "Auto story-ring (rings by itself every 15-30 min)"
default n
help
When enabled, the phone rings on its own at a random 15-30 minute
interval; picking up launches a random audio story straight away
(no menu). If nobody answers within 1 minute the bell stops and the
next ring is rescheduled. Default off — enable for the escape-room
ambience where the phone calls the players.
config PLIP_GATEWAY_URL
string "NPC Gateway Base URL"
default "http://192.168.0.50:8401"
+112 -1
View File
@@ -23,6 +23,7 @@
#include "phone.h"
#include "slic.h"
#include "turn_client.h"
#include "plip_gamebook.h"
#if CONFIG_PLIP_DIAL_DTMF
#include "dtmf.h"
#endif
@@ -32,6 +33,7 @@
#include "esp_log.h"
#include "esp_timer.h"
#include "esp_heap_caps.h"
#include "esp_random.h"
#include <stdio.h>
#include <string.h>
@@ -68,6 +70,7 @@ typedef enum {
STATE_BUSY,
STATE_GREET, /* fetching + playing NPC greeting (Stage 2) */
STATE_CONNECTED, /* in-call — Stage 3 will add listen loop */
STATE_GAMEBOOK, /* audio "livre dont vous êtes le héros" */
} conv_state_t;
static volatile conv_state_t s_state = STATE_IDLE;
@@ -96,6 +99,15 @@ static char s_incoming_number[16] = {0};
static char s_incoming_scene[40] = {0};
static volatile bool s_incoming_armed = false;
/* Auto story-ring: the phone rings on its own at a random interval; picking up
* launches a random audio story straight away. If nobody answers, the bell
* stops after STORY_RING_TIMEOUT_MS and the next ring is rescheduled. */
#define STORY_RING_MIN_MS (15 * 60 * 1000) /* 15 min */
#define STORY_RING_SPAN_MS (15 * 60 * 1000) /* + up to 15 min → 30 max */
#define STORY_RING_TIMEOUT_MS (60 * 1000) /* give up after 1 min */
static volatile bool s_story_ring_armed = false;
static int64_t s_story_ring_start_us = 0;
/* Known numbers: ringback when dialed */
static const char *KNOWN[] = {
"12", "3615", "15", "17", "18", "0142738200", NULL
@@ -125,10 +137,16 @@ static void scene_slug(const char *scene, char *out, size_t cap)
static void go_idle(void)
{
if (s_story_ring_armed) { /* hung up mid auto-ring → silence the bell */
phone_ring_stop();
slic_ring_stop();
s_story_ring_armed = false;
}
tones_stop();
audio_stop();
audio_pa_set(false);
dialer_reset();
plip_gamebook_end(); /* no-op if the gamebook wasn't running */
#if CONFIG_PLIP_DIAL_DTMF
dtmf_stop();
#endif
@@ -161,6 +179,53 @@ static void enter_incoming_greet(void)
ESP_LOGI(TAG, "INCOMING pickup -> GREET num=%s sid=%s", s_number, s_sid);
}
/* Answered an auto story-ring: stop the bell and launch a random story right
* into STATE_GAMEBOOK (no menu). Drives s_offhook directly because the
* debounced GPIO hook detection is unreliable while the bell rings. */
static void enter_story_from_ring(void)
{
s_story_ring_armed = false;
phone_ring_stop();
slic_ring_stop();
tones_stop();
dialer_reset();
s_offhook = true;
#if CONFIG_PLIP_DIAL_DTMF
dtmf_start();
#endif
audio_pa_set(true);
plip_gamebook_begin_random();
s_state = STATE_GAMEBOOK;
ESP_LOGI(TAG, "story-ring pickup -> GAMEBOOK (random story)");
}
#if CONFIG_PLIP_AUTO_RING
/* Rings the phone at a random 1530 min interval. Only rings when the line is
* truly idle (on-hook, no other call/ring armed) and a story pack is present.
* The 1-minute no-answer timeout is handled in conv_task. */
static void story_ring_task(void *arg)
{
(void)arg;
for (;;) {
uint32_t wait_ms = STORY_RING_MIN_MS + (esp_random() % STORY_RING_SPAN_MS);
ESP_LOGI(TAG, "auto story-ring: next attempt in %u min",
(unsigned)(wait_ms / 60000));
vTaskDelay(pdMS_TO_TICKS(wait_ms));
if (s_state == STATE_IDLE && !s_offhook && !s_incoming_armed
&& !s_story_ring_armed && plip_gamebook_available()) {
s_story_ring_armed = true;
s_story_ring_start_us = esp_timer_get_time();
phone_ring_start();
ESP_LOGI(TAG, "auto story-ring: ringing (up to %d s)",
STORY_RING_TIMEOUT_MS / 1000);
} else {
ESP_LOGI(TAG, "auto story-ring: line busy/unavailable — skipped");
}
}
}
#endif /* CONFIG_PLIP_AUTO_RING */
static void conv_task(void *arg)
{
(void)arg;
@@ -182,9 +247,23 @@ static void conv_task(void *arg)
} else {
/* Off-hook from idle */
if (s_state == STATE_IDLE) {
if (s_incoming_armed) {
if (s_story_ring_armed) {
/* Answered an auto story-ring → straight into a story. */
enter_story_from_ring();
} else if (s_incoming_armed) {
/* INCOMING call answered (phone.c detected the edge). */
enter_incoming_greet();
} else if (plip_gamebook_available()) {
/* Gamebook phone: a story pack is on the SD → pick up
* launches the audio "livre dont vous êtes le héros".
* Digits dialed choose the story then the answers. */
dialer_reset();
#if CONFIG_PLIP_DIAL_DTMF
dtmf_start();
#endif
plip_gamebook_begin();
s_state = STATE_GAMEBOOK;
ESP_LOGI(TAG, "off-hook -> GAMEBOOK");
} else {
/* Outgoing call: dial tone, wait for digits. */
dialer_reset();
@@ -208,6 +287,19 @@ static void conv_task(void *arg)
* line directly — it reads the pickup cleanly mid-ring. */
if (s_incoming_armed && slic_is_offhook()) {
enter_incoming_greet();
} else if (s_story_ring_armed) {
/* Same raw-SLIC poll for the auto story-ring, plus the
* no-answer timeout: stop the bell after 1 min unanswered. */
if (slic_is_offhook()) {
enter_story_from_ring();
} else if ((esp_timer_get_time() - s_story_ring_start_us) / 1000
>= STORY_RING_TIMEOUT_MS) {
phone_ring_stop();
slic_ring_stop();
s_story_ring_armed = false;
ESP_LOGI(TAG, "auto story-ring: no answer after %d s — stop",
STORY_RING_TIMEOUT_MS / 1000);
}
}
break;
@@ -224,6 +316,21 @@ static void conv_task(void *arg)
}
break;
case STATE_GAMEBOOK:
if (!s_offhook) {
go_idle();
break;
}
/* Each dialed digit drives one choice. Consume a single digit at a
* time (take the first, reset) so the player can dial again right
* away — even over the narration, which gets interrupted. */
if (!dialer_idle()) {
int d = dialer_current()[0] - '0';
dialer_reset();
plip_gamebook_feed_digit(d);
}
break;
case STATE_DIALING:
if (!s_offhook) {
go_idle();
@@ -454,6 +561,10 @@ void conversation_init(void)
xTaskCreate(conv_task, "conv", 8192, NULL, 4, NULL);
#else
xTaskCreate(conv_task, "conv", 6144, NULL, 4, NULL);
#endif
#if CONFIG_PLIP_AUTO_RING
/* Auto story-ring scheduler: rings every 1530 min when idle. */
xTaskCreate(story_ring_task, "storyring", 3072, NULL, 3, NULL);
#endif
ESP_LOGI(TAG, "conversation init");
}
+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;
+190
View File
@@ -0,0 +1,190 @@
// plip_gamebook.c — see plip_gamebook.h. Audio CYOA for the PLIP phone.
#include "plip_gamebook.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cJSON.h"
#include "esp_log.h"
#include "esp_random.h"
#include "audio.h"
static const char *TAG = "plip_gb";
#define GB_DIR "/sdcard/gamebook"
#define GB_LIBRARY GB_DIR "/library.json"
#define GB_MENU_WAV GB_DIR "/menu.wav"
#define GB_JSON_MAX (64 * 1024)
typedef enum { GB_OFF, GB_MENU, GB_STORY } gb_mode_t;
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 char s_current[48] = {0};
static gb_mode_t s_mode = GB_OFF;
bool plip_gamebook_active(void) { return s_mode != GB_OFF; }
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); return NULL; }
char *buf = malloc((size_t)sz + 1);
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);
free(buf);
if (!root) ESP_LOGW(TAG, "malformed JSON: %s", path);
return root;
}
static void play_wav(const char *file) // file = bare name under GB_DIR
{
char path[96];
snprintf(path, sizeof(path), "%s/%s", GB_DIR, file);
audio_stop(); // interrupt any current narration
audio_play_async(path);
}
bool plip_gamebook_available(void)
{
if (!audio_ensure_sd()) return false;
FILE *f = fopen(GB_LIBRARY, "rb");
if (!f) return false;
fclose(f);
return true;
}
static void free_story(void)
{
if (s_book) { cJSON_Delete(s_book); s_book = NULL; }
s_passages = NULL;
s_current[0] = '\0';
}
static void enter_menu(void)
{
free_story();
s_mode = GB_MENU;
audio_stop();
audio_play_async(GB_MENU_WAV); // absolute path
ESP_LOGI(TAG, "menu (%d stories)", s_lib_n);
}
// Play a passage by id (its WAV narrates the text + numbered choices).
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; }
snprintf(s_current, sizeof(s_current), "%s", pid);
const cJSON *wav = cJSON_GetObjectItem(p, "wav");
if (cJSON_IsString(wav) && wav->valuestring[0]) play_wav(wav->valuestring);
const cJSON *ch = cJSON_GetObjectItem(p, "choices");
ESP_LOGI(TAG, "passage '%s' (%d choices)", pid,
cJSON_IsArray(ch) ? cJSON_GetArraySize(ch) : 0);
}
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;
}
free_story();
s_book = book;
s_passages = (cJSON *)passages;
s_mode = GB_STORY;
ESP_LOGI(TAG, "load book #%d @ '%s'", idx, start->valuestring);
enter_passage(start->valuestring);
}
void plip_gamebook_begin(void)
{
plip_gamebook_end(); // clean any previous run
s_lib_root = load_json(GB_LIBRARY);
if (!s_lib_root) 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;
return;
}
s_lib_n = cJSON_GetArraySize(s_lib);
audio_pa_set(true); // earpiece amplifier on
enter_menu();
}
void plip_gamebook_begin_random(void)
{
plip_gamebook_end(); // clean any previous run
s_lib_root = load_json(GB_LIBRARY);
if (!s_lib_root) 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;
return;
}
s_lib_n = cJSON_GetArraySize(s_lib);
audio_pa_set(true); // earpiece amplifier on
int idx = (int)(esp_random() % (uint32_t)s_lib_n);
ESP_LOGI(TAG, "auto-start random story #%d/%d", idx, s_lib_n);
load_book(idx); // straight into the story, no menu
}
void plip_gamebook_feed_digit(int d)
{
if (s_mode == GB_MENU) {
if (d >= 1 && d <= s_lib_n) load_book(d - 1);
return;
}
if (s_mode == GB_STORY) {
const cJSON *p = cJSON_GetObjectItem(s_passages, s_current);
const cJSON *ch = cJSON_GetObjectItem(p, "choices");
int n = cJSON_IsArray(ch) ? cJSON_GetArraySize(ch) : 0;
if (n == 0) { // ending: 0 (or any) → back to menu
enter_menu();
return;
}
if (d >= 1 && d <= n) {
const cJSON *g = cJSON_GetObjectItem(cJSON_GetArrayItem(ch, d - 1),
"goto");
if (cJSON_IsString(g)) {
ESP_LOGI(TAG, "choice %d -> '%s'", d, g->valuestring);
enter_passage(g->valuestring);
}
}
// out-of-range digit: ignore (player can dial again)
}
}
void plip_gamebook_end(void)
{
if (s_mode == GB_OFF && !s_lib_root && !s_book) return;
s_mode = GB_OFF;
audio_stop();
free_story();
if (s_lib_root) { cJSON_Delete(s_lib_root); s_lib_root = NULL; s_lib = NULL; }
ESP_LOGI(TAG, "ended");
}
+48
View File
@@ -0,0 +1,48 @@
#pragma once
// plip_gamebook — "livre dont vous êtes le héros" AUDIO pour le téléphone PLIP.
//
// Pas d'écran : tout passe par l'écouteur. On décroche, le narrateur lit le
// menu des histoires ("pour le dragon, faites le 1..."), on compose un chiffre
// au cadran pour choisir une histoire, puis à chaque passage le narrateur lit
// le texte ET les choix numérotés ("pour examiner la machine, faites le 1..."),
// et on compose le chiffre du choix. Raccrocher arrête tout.
//
// Lit /sdcard/gamebook/{library.json, menu.wav, <id>.json, <id>_<passage>.wav}
// (pack audio "téléphone" produit par tools/gamebook/build_phone_gamebook.py,
// où la narration des WAV inclut déjà les invites "faites le N"). 100% local,
// aucun modèle, aucun réseau.
//
// Intégration : conversation.c lance plip_gamebook_begin() au décroché (si un
// pack est présent sur la SD), pousse chaque chiffre composé via
// plip_gamebook_feed_digit(), et appelle plip_gamebook_end() au raccroché.
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
// La SD est-elle montée et un pack gamebook présent ? (à tester au décroché)
bool plip_gamebook_available(void);
// Démarre le mode livre-jeu : charge la bibliothèque et joue le menu audio.
void plip_gamebook_begin(void);
// Démarre directement UNE histoire tirée au hasard (sans passer par le menu) —
// utilisé quand le téléphone sonne tout seul et qu'on décroche.
void plip_gamebook_begin_random(void);
// Traite un chiffre composé (0..9). Menu → choisit l'histoire ; en histoire →
// choisit la réponse ; sur une fin → 0 revient au menu. Interrompt la
// narration en cours pour enchaîner.
void plip_gamebook_feed_digit(int digit);
// Quitte le mode livre-jeu (raccroché) : stoppe l'audio, libère la mémoire.
void plip_gamebook_end(void);
// Vrai tant qu'une partie est en cours.
bool plip_gamebook_active(void);
#ifdef __cplusplus
}
#endif
+3
View File
@@ -56,3 +56,6 @@ CONFIG_PLIP_HOOK_ACTIVE_HIGH=y
# Long file names on the SD (voice sample pack uses >8.3 names)
CONFIG_FATFS_LFN_HEAP=y
CONFIG_FATFS_MAX_LFN=255
# Speaker at max (earpiece)
CONFIG_PLIP_SPEAKER_VOLUME=100