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.
This commit was merged in pull request #40.
This commit is contained in:
clement
2026-06-19 23:55:41 +02:00
parent 0027970907
commit c2527a0a66
4 changed files with 245 additions and 0 deletions
+1
View File
@@ -13,6 +13,7 @@ idf_component_register(
"dtmf.c" "dtmf.c"
"turn_client.c" "turn_client.c"
"slic.c" "slic.c"
"plip_gamebook.c"
INCLUDE_DIRS "." INCLUDE_DIRS "."
PRIV_REQUIRES PRIV_REQUIRES
driver driver
+29
View File
@@ -23,6 +23,7 @@
#include "phone.h" #include "phone.h"
#include "slic.h" #include "slic.h"
#include "turn_client.h" #include "turn_client.h"
#include "plip_gamebook.h"
#if CONFIG_PLIP_DIAL_DTMF #if CONFIG_PLIP_DIAL_DTMF
#include "dtmf.h" #include "dtmf.h"
#endif #endif
@@ -68,6 +69,7 @@ typedef enum {
STATE_BUSY, STATE_BUSY,
STATE_GREET, /* fetching + playing NPC greeting (Stage 2) */ STATE_GREET, /* fetching + playing NPC greeting (Stage 2) */
STATE_CONNECTED, /* in-call — Stage 3 will add listen loop */ STATE_CONNECTED, /* in-call — Stage 3 will add listen loop */
STATE_GAMEBOOK, /* audio "livre dont vous êtes le héros" */
} conv_state_t; } conv_state_t;
static volatile conv_state_t s_state = STATE_IDLE; static volatile conv_state_t s_state = STATE_IDLE;
@@ -129,6 +131,7 @@ static void go_idle(void)
audio_stop(); audio_stop();
audio_pa_set(false); audio_pa_set(false);
dialer_reset(); dialer_reset();
plip_gamebook_end(); /* no-op if the gamebook wasn't running */
#if CONFIG_PLIP_DIAL_DTMF #if CONFIG_PLIP_DIAL_DTMF
dtmf_stop(); dtmf_stop();
#endif #endif
@@ -185,6 +188,17 @@ static void conv_task(void *arg)
if (s_incoming_armed) { if (s_incoming_armed) {
/* INCOMING call answered (phone.c detected the edge). */ /* INCOMING call answered (phone.c detected the edge). */
enter_incoming_greet(); 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 { } else {
/* Outgoing call: dial tone, wait for digits. */ /* Outgoing call: dial tone, wait for digits. */
dialer_reset(); dialer_reset();
@@ -224,6 +238,21 @@ static void conv_task(void *arg)
} }
break; 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: case STATE_DIALING:
if (!s_offhook) { if (!s_offhook) {
go_idle(); go_idle();
+171
View File
@@ -0,0 +1,171 @@
// 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 "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_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");
}
+44
View File
@@ -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, <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);
// 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