diff --git a/plip_voice/main/CMakeLists.txt b/plip_voice/main/CMakeLists.txt index e1b89d9..9e94048 100644 --- a/plip_voice/main/CMakeLists.txt +++ b/plip_voice/main/CMakeLists.txt @@ -13,6 +13,7 @@ idf_component_register( "dtmf.c" "turn_client.c" "slic.c" + "plip_gamebook.c" INCLUDE_DIRS "." PRIV_REQUIRES driver diff --git a/plip_voice/main/conversation.c b/plip_voice/main/conversation.c index 8f70492..36937a5 100644 --- a/plip_voice/main/conversation.c +++ b/plip_voice/main/conversation.c @@ -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 @@ -68,6 +69,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; @@ -129,6 +131,7 @@ static void go_idle(void) 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 @@ -185,6 +188,17 @@ static void conv_task(void *arg) 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(); @@ -224,6 +238,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(); diff --git a/plip_voice/main/plip_gamebook.c b/plip_voice/main/plip_gamebook.c new file mode 100644 index 0000000..954af96 --- /dev/null +++ b/plip_voice/main/plip_gamebook.c @@ -0,0 +1,171 @@ +// plip_gamebook.c — see plip_gamebook.h. Audio CYOA for the PLIP phone. +#include "plip_gamebook.h" + +#include +#include +#include + +#include "cJSON.h" +#include "esp_log.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 .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_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"); +} diff --git a/plip_voice/main/plip_gamebook.h b/plip_voice/main/plip_gamebook.h new file mode 100644 index 0000000..20971eb --- /dev/null +++ b/plip_voice/main/plip_gamebook.h @@ -0,0 +1,44 @@ +#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, .json, _.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 + +#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); + +// 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