diff --git a/box3_voice/main/cmd_exec.c b/box3_voice/main/cmd_exec.c index 7a51862..0189c5e 100644 --- a/box3_voice/main/cmd_exec.c +++ b/box3_voice/main/cmd_exec.c @@ -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) diff --git a/box3_voice/main/cmd_exec.h b/box3_voice/main/cmd_exec.h index cebc883..3d7d277 100644 --- a/box3_voice/main/cmd_exec.h +++ b/box3_voice/main/cmd_exec.h @@ -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 diff --git a/box3_voice/main/gamebook.c b/box3_voice/main/gamebook.c index 1ee14ce..d89496f 100644 --- a/box3_voice/main/gamebook.c +++ b/box3_voice/main/gamebook.c @@ -11,6 +11,7 @@ #include "bsp/esp-bsp.h" #include "lvgl.h" +#include "cmd_exec.h" // async WAV narration player static const char *TAG = "gamebook"; @@ -124,6 +125,7 @@ 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"); @@ -147,6 +149,15 @@ static void enter_passage(const char *pid) 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);