feat(plip): audio gamebook on the phone (dial to choose)
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:
@@ -13,6 +13,7 @@ idf_component_register(
|
||||
"dtmf.c"
|
||||
"turn_client.c"
|
||||
"slic.c"
|
||||
"plip_gamebook.c"
|
||||
INCLUDE_DIRS "."
|
||||
PRIV_REQUIRES
|
||||
driver
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user