Compare commits

...

20 Commits

Author SHA1 Message Date
clement f58b090a34 feat: gamebook mode (CYOA) on the master
CI / platformio (pull_request) Failing after 4m23s
CI / platformio (push) Failing after 9m52s
Context:
We wanted a "livre dont vous etes le heros" playable on the Freenove
master with NO model in RAM: narration as say()-rendered WAV staged on
the SD card, and navigation on the physical 5-way pad. Nothing in the
firmware drove a branching, button-navigated story before this.

Approach:
A self-contained gamebook component reads a JSON book from the SD,
drives the display and audio, and owns the 5-way pad through a
registered display_ui key hook (so display_ui keeps no dependency on
gamebook — no cycle). A dedicated full-screen gamebook view renders
each page (title / wrapped body / choice list).

Changes:
- New gamebook component: loads /sdcard/gamebook/gamebook.json; per
  passage plays its WAV (media_manager, absolute /sdcard path) and
  renders the title, wrapped text, and a choice list with a ">" cursor.
- Navigation: D-pad up/down (and left/right) move the cursor, the
  center click confirms; endings (no choices) restart on click; a
  choice interrupts the current narration (media_manager_stop + play).
- display_ui: add display_ui_set_key_hook (consume keys when a takeover
  mode is active) and a dedicated gamebook view (title + height-bounded
  body band + reserved bottom menu band) with display_ui_gamebook_show
  / display_ui_gamebook_hide; the gamebook view wins the view selector
  while open.
- game_endpoint: POST /game/gamebook[?action=stop] to start/stop.
- ota_server: bump max_uri_handlers 20 -> 22 for the new endpoint.
- main: call gamebook_init() once the buttons are up.

Impact:
The Freenove plays a branching audio gamebook driven entirely by the
pad, fully offline (no gateway, no model). Hardware-validated end to
end: cursor navigation, click select, WAV narration from SD, endings
restart.
2026-06-19 14:30:37 +02:00
clement f3d03c637a feat(plip): add DELETE /game/file (SD/SPIFFS)
CI / platformio (push) Failing after 4m10s
CI / platformio (pull_request) Failing after 10m42s
Removes a staged file with the same sd/ vs SPIFFS path routing as the
POST handler (mounts SD on demand). Lets the host clean stale clips
when regenerating the SD voice pack. max_uri_handlers 17->18.
Validated: probe WAVs removed from /sdcard/voice (200, then 404).
2026-06-18 21:57:20 +02:00
clement 39e0498221 feat(plip): chain SD scene hint after greeting
CI / platformio (push) Failing after 4m24s
CI / platformio (pull_request) Failing after 5m51s
/debug/ring now takes an optional scene; conversation_arm_incoming
locks it and, after the greeting, queues that scene's local clip
/sdcard/voice/hint_<scene>_l1_0.wav (played back-to-back). For a
scripted offline call CONNECTED skips the gateway/model listen loop
and just waits for hang-up. Scene is cleared in go_idle so a later
dial-out call never inherits it. Validated: greeting 5.05s + WARNING
hint 6.20s from SD on pickup, no gateway/model.
2026-06-18 21:31:18 +02:00
clement da7b6ace9c feat(plip): play greeting from SD pack on pickup
CI / platformio (pull_request) Failing after 4m2s
CI / platformio (push) Failing after 9m58s
On incoming pickup the GREET state now plays the local clip
/sdcard/voice/greet_<num>.wav when present — the phone greets the
caller with no gateway and no model in RAM. Falls back to the live
gateway greeting (turn_client) only when the pack lacks a clip for
that number.
2026-06-18 18:08:29 +02:00
clement be369fa260 feat(plip): stage files onto SD via /game/file
CI / platformio (push) Failing after 10m32s
CI / platformio (pull_request) Failing after 11m45s
/game/file only wrote SPIFFS (960 KB, too small for a voice pack). A
path prefixed sd/ (or /sdcard/) now routes to the microSD: the card is
mounted on demand (audio_ensure_sd, independent of hook state) and the
parent dir is created. MAX_BODY 256->512 KB so full say() hints fit
(streamed in 2 KB chunks, RAM-safe). Enable FATFS LFN_HEAP so >8.3
filenames work (greet_0142738200.wav etc). Validated: 40-sample FR
voice pack staged to /sdcard/voice on the bench PLIP (960 MB card).
2026-06-18 17:41:53 +02:00
clement ace740a629 fix(npc): wire scene notify into apply_step
CI / platformio (push) Failing after 6m10s
CI / platformio (pull_request) Failing after 17m59s
notify_gateway_scene() + hints puzzle_start lived only in
npc_engine_set_step(), which had NO callers — the runtime->gateway
sync was dead code, so scripted incoming calls would never fire in a
real game. POST /game/step goes through game_endpoint_apply_step(),
which now resolves the step's scene_id (new field on scene_binding,
parsed straight from the IR) and calls the new
npc_engine_set_scene_by_id(). Hardware-validated: STEP_WARNING ->
scene 3 -> gateway notify -> auto-ring Professeur Zacus on the PLIP.
2026-06-18 15:52:03 +02:00
clement 4ac3981845 fix(npc): gateway scene notify non-blocking
CI / platformio (pull_request) Failing after 4m3s
CI / platformio (push) Failing after 14m44s
The scene->gateway POST ran synchronously (2 s timeout) on the scene
transition, so an offline gateway lagged EVERY scene change by 2 s.
Fire it on a detached one-shot task instead, so the game's scene
transitions never wait on the network. Frees the heap scene copy and
self-deletes. Builds clean.
2026-06-18 15:17:31 +02:00
clement 653a299ea4 feat(npc): push scene changes to voice gateway
CI / platformio (pull_request) Failing after 4m58s
CI / platformio (push) Failing after 12m35s
On each scene change (where it already signals /hints/puzzle_start), the
npc_engine now also POSTs the active SCENE_* to the voice gateway
/game/step?scene=… (best-effort, esp_http_client, Bearer token). The
phone NPCs then disguise THIS scene's hint automatically during a game.
Gateway URL/token hardcoded in main.c like the hints URL (-> NVS later).
2026-06-18 14:04:07 +02:00
clement 54022ed6cc fix(plip): VAD calib for quiet voice + ring stop
CI / platformio (pull_request) Failing after 9m38s
CI / platformio (push) Failing after 13m49s
Recalibrate the capture VAD to the quiet handset voice: onset 1.4%->0.7%
FS (was never triggering -> 'no sustained voice'), silence 0.6%->0.34%
FS and end-of-speech window 600->900 ms so a whole sentence is held
instead of cut mid-phrase. Also force slic_ring_stop() on incoming
pickup so the physical bell always stops when answered (phone.c/slic.c
s_ringing could desync, leaving the bell ringing through the call).
2026-06-17 23:47:08 +02:00
clement cfe429d885 fix(plip): debounce hook hangup against flicker
CI / platformio (pull_request) Failing after 5m23s
CI / platformio (push) Failing after 12m32s
The marginal A1S cradle contact flickers open mid-call; treating a
brief open as a hangup dropped the live conversation. Raise the
prolonged-open hangup threshold to 2.5 s and make the resync
asymmetric: a PICKUP is confirmed fast (600 ms, calls answer promptly)
while a HANGUP needs the line to stay open 2.5 s. Brief flickers no
longer end the call; a real hangup still fires ~2.5 s later.
2026-06-17 19:47:55 +02:00
clement 82759ee536 feat(plip): voice-activated two-phase capture
CI / platformio (push) Failing after 9m50s
CI / platformio (pull_request) Failing after 10m14s
Listen-loop capture now only commits when the caller actually speaks:
phase A waits for a sustained voice onset (3 frames above threshold,
rejecting the PA-mute click), phase B records until silence. Empty
captures are no longer posted, so the NPC never replies to silence.
Also: DC-blocking high-pass on the captured mono, VAD thresholds tuned
to the quiet SLIC handset mic (onset ~1.4%, silence ~0.6%), keep the
PA on during capture (muting it collapsed the mic), and add a
/debug/miccap diagnostic endpoint (raw fixed-duration mic capture).
2026-06-17 14:39:42 +02:00
clement 8c076d81d6 fix(plip): trust real SHK, drop hook forcing
CI / platformio (pull_request) Failing after 7m44s
CI / platformio (push) Failing after 11m19s
Incoming-call greeting was silently dropped: audio CMD_PLAY gated on
phone.c's debounced phone_is_offhook(), which lags/misses the pickup
while the bell rings, but the incoming flow established off-hook only
via the raw SLIC poll in conversation.c. The two flags desynced and
playback was skipped as 'on-hook' though the handset was up.

Gate playback on the real SHK line (slic_is_offhook) instead — the
single source of truth. Remove all hook forcing (s_hook_override,
phone_force_offhook, /debug/offhook): the SHK contact is reliable in
hardware, so the firmware must trust it, never override it. Builds
clean (ESP-IDF v5.4.4).
2026-06-17 09:01:48 +02:00
clement 99d79efcdd tune(plip): +24dB mic PGA + VAD thresholds
CI / platformio (push) Failing after 11m27s
2026-06-16 19:18:52 +02:00
clement 8dffe14972 fix(plip): mute earpiece during capture 2026-06-16 19:18:45 +02:00
clement d71453d32e fix(plip): report real state in /status 2026-06-16 19:18:38 +02:00
clement ccc84da785 tune(plip): default speaker volume 98%
CI / platformio (push) Failing after 15m51s
2026-06-16 10:32:11 +02:00
clement 1561e613e1 feat(plip): incoming call + fast half-duplex
- /debug/ring?number=NN arms incoming call, starts ring
- conversation_arm_incoming() + enter_incoming_greet()
- offhook with armed call skips dial/ringback, goes to GREET
- slic_is_offhook() used during ring (GPIO debounce unreliable)
- CAPTURE_SILENCE_MS 800->600 ms, settles 250->120, 200->100 ms
2026-06-16 10:32:01 +02:00
clement 4a6fc435a7 fix(plip): ES8388 undoc DLL regs for 16kHz ADC
CI / platformio (push) Failing after 13m6s
2026-06-16 09:32:36 +02:00
clement ebcfb011d3 feat(plip): /debug/es8388 register poke endpoint
CI / platformio (push) Failing after 11m53s
Add es8388_write_reg() public API and GET /debug/es8388?reg=&val=
HTTP handler to read/write codec registers at runtime without
reflashing. Bumps max_uri_handlers 16→17.
2026-06-16 08:40:59 +02:00
clement 3c0eb75465 fix(plip): robust WiFi on mesh/DFS network
STA config: channel=0 (all-channel scan), WIFI_ALL_CHANNEL_SCAN,
WIFI_CONNECT_AP_BY_SIGNAL, failure_retry_cnt=5.
Disconnect handler: retry on every disconnect event, including during
initial association — previously the first failure at boot caused an
immediate abort and a ~30s timeout before IP was acquired.
Validated on hardware: connects reliably on ch1, RSSI -32, IP in ~2.5s.
2026-06-15 22:27:01 +02:00
26 changed files with 1034 additions and 143 deletions
@@ -248,6 +248,16 @@ static bool s_browser_isdir[BROWSER_MAX_ENTRIES];
static uint8_t s_browser_count = 0;
static volatile uint8_t s_browser_sel = 0;
static volatile bool s_browser_open = false;
// ── Gamebook view ("livre dont vous êtes le héros") ─────────────────────────
static lv_obj_t *s_scr_gamebook;
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 char s_gb_title_buf[64];
static char s_gb_body_buf[512];
static char s_gb_menu_buf[160];
static volatile bool s_browser_reload = false;
// ─── Intro — faithful port of the original cracktro ──────────────────────────
@@ -761,6 +771,44 @@ scroller:
lv_obj_set_pos(s_intro_scroll, sx, sy);
}
static void build_gamebook_screen(void) {
s_scr_gamebook = lv_obj_create(NULL);
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);
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_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_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);
// Body band: top area, height-bounded so it can never run into the menu.
s_gb_body = lv_label_create(s_scr_gamebook);
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_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);
}
static void build_status_screen(void) {
lv_obj_t *scr = s_scr_status;
lv_obj_set_style_bg_color(scr, lv_color_hex(COL_BG), 0);
@@ -853,10 +901,19 @@ 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.
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);
}
extern volatile uint8_t g_dui_view_override;
lv_obj_t *want;
if (!s_intro_done) {
want = s_scr_intro; // boot intro plays out first
} else if (s_gamebook_open) {
want = s_scr_gamebook; // gamebook owns the screen while running
} else if (s_browser_open) {
want = s_scr_browser; // file browser on top of everything
} else if (s_shell_open) {
@@ -964,6 +1021,7 @@ static void display_task(void *arg) {
build_status_screen();
build_scene_screen();
build_shell_screen();
build_gamebook_screen();
build_browser_screen();
build_intro_screen();
@@ -1145,9 +1203,36 @@ extern "C" void display_ui_set_status(const display_status_t *s) {
}
}
static display_ui_key_hook_t s_key_hook = nullptr;
extern "C" void display_ui_set_key_hook(display_ui_key_hook_t hook) {
s_key_hook = hook;
}
extern "C" void display_ui_gamebook_show(const char *title, const char *body,
const char *menu) {
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 : "");
s_gamebook_open = true;
s_dirty = true;
if (s_mutex) xSemaphoreGive(s_mutex);
}
extern "C" void display_ui_gamebook_hide(void) {
s_gamebook_open = false;
s_dirty = true;
}
extern "C" void display_ui_handle_key(uint8_t key) {
static uint8_t s_brightness = 100;
// Takeover mode (e.g. gamebook): if the hook consumes the key, stop here.
if (s_key_hook && s_key_hook(key)) {
return;
}
// Any key skips the boot intro.
if (!s_intro_done) {
s_intro_done = true;
@@ -78,6 +78,36 @@ void display_ui_camera_frame(const uint8_t *gray, int width, int height);
*/
void display_ui_handle_key(uint8_t key);
/**
* @brief Install an optional key interceptor.
*
* If a hook is set and returns true for a given key, the key is consumed and
* the normal shell/scene handling is skipped. Lets a takeover mode (e.g. the
* gamebook) own the 5-way pad without display_ui having to depend on it.
* Pass NULL to remove the hook. Thread-safe (single pointer write).
*/
typedef bool (*display_ui_key_hook_t)(uint8_t key);
void display_ui_set_key_hook(display_ui_key_hook_t hook);
/**
* @brief Show a full-screen gamebook page (takes over the display).
*
* Dedicated "livre dont vous êtes le héros" view: a centred title, the full
* passage text wrapped over multiple lines, and a choice menu at the bottom.
* Forces this view until display_ui_gamebook_hide(). Thread-safe (buffers
* copied under the mutex; the actual LVGL render runs on the display task).
* Strings should be ASCII (the embedded fonts have no accents).
*
* @param title short page title (top)
* @param body full passage text (wrapped); may be long
* @param menu choice lines (bottom), e.g. "[OK] ...\n[<>] ..."
*/
void display_ui_gamebook_show(const char *title, const char *body,
const char *menu);
/** @brief Leave the gamebook view and return to the normal scene/status flow. */
void display_ui_gamebook_hide(void);
/**
* @brief Pop the pending shell-app launch request, if any.
*
@@ -17,9 +17,11 @@ idf_component_register(
puzzle_state
media_manager
sd_storage
gamebook
PRIV_REQUIRES
local_puzzles
p7_coffre
p5_morse
p6_nfc
npc_engine
)
@@ -27,6 +27,8 @@
#include "nvs_flash.h"
#include "hints_client.h"
#include "gamebook.h"
#include "npc_engine.h"
#include "scenario_mesh.h"
#include "puzzle_binding.h"
#include "local_puzzles.h"
@@ -807,6 +809,16 @@ esp_err_t game_endpoint_apply_step(const char *step_id,
strncpy(s_current_step_id, step_id, sizeof(s_current_step_id) - 1);
s_current_step_id[sizeof(s_current_step_id) - 1] = '\0';
// Sync the NPC/hints engine (and, through it, the voice gateway) with the
// scene this step enters. This is what makes the phone NPCs hint on — and
// scripted calls auto-ring for — the current scene, regardless of whether
// the step was driven by the local game loop or an external POST /game/step.
// Best-effort: notify is async + server-side idempotent and never blocks
// the step change. Steps with no scene_id (or an unknown one) are skipped.
if (s_current_scene.scene_id[0] != '\0') {
(void) npc_engine_set_scene_by_id(s_current_scene.scene_id, 0);
}
// P7 coffre: fire the actuator when the final win step is reached.
// p7_coffre_unlock() is a no-op stub when CONFIG_ZACUS_P7_COFFRE_ENABLE=n.
if (strcmp(step_id, "STEP_FINAL_WIN") == 0) {
@@ -983,6 +995,29 @@ static esp_err_t handle_media_play_post(httpd_req_t *req) {
return send_error(req, "500 Internal Server Error", esp_err_to_name(err));
}
// ─── POST /game/gamebook[?action=stop] ───────────────────────────────────────
//
// Start the "livre dont vous êtes le héros" mode (loads /sdcard/gamebook/
// gamebook.json, plays the start passage, takes over the 5-way pad). With
// ?action=stop it leaves gamebook mode. No body required.
static esp_err_t handle_gamebook_post(httpd_req_t *req) {
char query[32] = {0}, action[16] = {0};
if (httpd_req_get_url_query_str(req, query, sizeof(query)) == ESP_OK) {
httpd_query_key_value(query, "action", action, sizeof(action));
}
if (strcmp(action, "stop") == 0) {
gamebook_stop();
return send_json(req, "200 OK", "{\"gamebook\":\"stopped\"}");
}
esp_err_t err = gamebook_start();
if (err != ESP_OK) {
return send_error(req, "503 Service Unavailable",
err == ESP_ERR_NOT_FOUND ? "gamebook_not_on_sd"
: "gamebook_load_failed");
}
return send_json(req, "200 OK", "{\"gamebook\":\"started\"}");
}
// ─── GET /game/puzzle_state ──────────────────────────────────────────────────
//
// Returns {"step_id":"STEP_X"|null, "solved":[1,3], "code":"125"}.
@@ -1221,6 +1256,12 @@ esp_err_t game_endpoint_init(httpd_handle_t server) {
.handler = handle_media_play_post,
.user_ctx = NULL,
};
static const httpd_uri_t uri_gamebook_post = {
.uri = "/game/gamebook",
.method = HTTP_POST,
.handler = handle_gamebook_post,
.user_ctx = NULL,
};
// Bring up the ESP-NOW mesh transport. The master is primarily a sender
// (the relay handler) but we also register the apply adapter so a peer
@@ -1297,6 +1338,10 @@ esp_err_t game_endpoint_init(httpd_handle_t server) {
if (err != ESP_OK) {
ESP_LOGW(TAG, "register POST /game/media/play: %s", esp_err_to_name(err));
}
err = httpd_register_uri_handler(server, &uri_gamebook_post);
if (err != ESP_OK) {
ESP_LOGW(TAG, "register POST /game/gamebook: %s", esp_err_to_name(err));
}
ESP_LOGI(TAG, "game endpoint registered "
"(GET+POST /game/group_profile, POST /game/scenario%s, "
@@ -55,6 +55,7 @@ esp_err_t puzzle_binding_from_ir(const char *ir_json, const char *step_id,
#define SB_MAX_TITLE 48
#define SB_MAX_SUBTITLE 64
#define SB_MAX_SYMBOL 16
#define SB_MAX_SCENE_ID 40
typedef enum {
SB_FX_PULSE = 0, // default (original SceneEffect::kPulse)
@@ -65,6 +66,8 @@ typedef enum {
typedef struct {
bool present; // step has a scene object
char scene_id[SB_MAX_SCENE_ID]; // canonical SCENE_* id (sibling of step id;
// set even when no display scene object)
char title[SB_MAX_TITLE];
char subtitle[SB_MAX_SUBTITLE];
char symbol[SB_MAX_SYMBOL];
@@ -239,9 +239,15 @@ esp_err_t scene_binding_from_ir(const char *ir_json, const char *step_id,
}
if (!step) { cJSON_Delete(root); return ESP_ERR_NOT_FOUND; }
// Canonical scene id (SCENE_*), sibling of the step "id". Captured
// unconditionally so npc_engine / the voice gateway can be synced even
// on steps that carry no display "scene" object.
copy_scene_str(out->scene_id, sizeof(out->scene_id),
cJSON_GetObjectItemCaseSensitive(step, "scene_id"));
const cJSON *scene = cJSON_GetObjectItemCaseSensitive(step, "scene");
if (!scene || !cJSON_IsObject(scene)) {
// No scene — present stays false, ESP_OK.
// No scene object — present stays false, ESP_OK (scene_id may be set).
cJSON_Delete(root);
return ESP_OK;
}
@@ -0,0 +1,11 @@
idf_component_register(
SRCS
"gamebook.c"
INCLUDE_DIRS
"include"
REQUIRES
json
display_ui
media_manager
log
)
+188
View File
@@ -0,0 +1,188 @@
// gamebook.c — see gamebook.h. Standalone "livre dont vous êtes le héros".
#include "gamebook.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cJSON.h"
#include "esp_log.h"
#include "display_ui.h"
#include "media_manager.h"
static const char *TAG = "gamebook";
#define GAMEBOOK_DIR "/sdcard/gamebook"
#define GAMEBOOK_JSON GAMEBOOK_DIR "/gamebook.json"
#define GAMEBOOK_MAX (32 * 1024) /* JSON sanity cap */
/* 5-way pad codes (mirror display_ui): 1=SELECT 2=DOWN 3=MENU 4=L/R 5=UP. */
#define KEY_MENU 3
static cJSON *s_root = NULL; /* owns the parsed JSON while active */
static cJSON *s_passages = NULL; /* borrowed: s_root->"passages" */
static char s_title[48] = {0};
static char s_current[48] = {0};
static int s_sel = 0; /* highlighted choice index */
static volatile bool s_active = false;
bool gamebook_active(void) { return s_active; }
static const cJSON *cur_passage(void)
{
return cJSON_GetObjectItem(s_passages, s_current);
}
static int cur_choice_count(void)
{
const cJSON *ch = cJSON_GetObjectItem(cur_passage(), "choices");
return cJSON_IsArray(ch) ? cJSON_GetArraySize(ch) : 0;
}
/* (Re)draw the current page: title + wrapped text + choice list with a "> "
* cursor on s_sel. Does NOT touch audio — call on every cursor move. */
static void render_page(void)
{
const cJSON *p = cur_passage();
if (!cJSON_IsObject(p)) return;
const cJSON *screen = cJSON_GetObjectItem(p, "screen");
const cJSON *text = cJSON_GetObjectItem(p, "text");
const cJSON *choices = cJSON_GetObjectItem(p, "choices");
int n = cJSON_IsArray(choices) ? cJSON_GetArraySize(choices) : 0;
char menu[256];
if (n == 0) {
snprintf(menu, sizeof(menu), "~ Fin ~ (clic = recommencer)");
} 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 : "?");
}
}
display_ui_gamebook_show(
cJSON_IsString(screen) ? screen->valuestring : s_title,
cJSON_IsString(text) ? text->valuestring : "",
menu);
}
/* Enter a passage: reset the cursor, draw the page, play its WAV from the SD. */
static void enter_passage(const char *pid)
{
const cJSON *p = cJSON_GetObjectItem(s_passages, 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();
const cJSON *wav = cJSON_GetObjectItem(p, "wav");
if (cJSON_IsString(wav) && wav->valuestring[0]) {
char path[96];
snprintf(path, sizeof(path), "%s/%s", GAMEBOOK_DIR, wav->valuestring);
media_manager_stop(); /* a choice skips the narration */
media_manager_play(path);
}
ESP_LOGI(TAG, "passage '%s' (%d choices)", pid, cur_choice_count());
}
/* Key hook (we own the 5-way pad while active). D-pad navigates the choice
* list, click (SELECT) confirms. Ladder codes: 1=click 2=down 3/4=left/right
* 5=up. up/left → previous, down/right → next. On an ending (no choices) the
* click restarts the book. Returns true to consume the key. */
static bool gamebook_key_hook(uint8_t key)
{
if (!s_active) return false;
int n = cur_choice_count();
if (n == 0) { /* ending page */
if (key == 1) { /* click → restart */
const cJSON *start = cJSON_GetObjectItem(s_root, "start");
if (cJSON_IsString(start)) enter_passage(start->valuestring);
}
return true;
}
switch (key) {
case 5: case 3: /* up / left → previous */
s_sel = (s_sel - 1 + n) % n; render_page(); break;
case 2: case 4: /* down / right → next */
s_sel = (s_sel + 1) % n; render_page(); break;
case 1: { /* click → confirm */
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);
enter_passage(g->valuestring);
}
break;
}
default: break;
}
return true;
}
esp_err_t gamebook_start(void)
{
gamebook_stop(); /* clean any previous run */
FILE *f = fopen(GAMEBOOK_JSON, "rb");
if (!f) {
ESP_LOGW(TAG, "open %s failed — SD card / pack missing?", GAMEBOOK_JSON);
return ESP_ERR_NOT_FOUND;
}
fseek(f, 0, SEEK_END);
long sz = ftell(f);
rewind(f);
if (sz <= 0 || sz > GAMEBOOK_MAX) { fclose(f); return ESP_ERR_INVALID_SIZE; }
char *buf = malloc((size_t)sz + 1);
if (!buf) { fclose(f); return ESP_ERR_NO_MEM; }
size_t rd = fread(buf, 1, (size_t)sz, f);
fclose(f);
buf[rd] = '\0';
s_root = cJSON_Parse(buf);
free(buf);
if (!s_root) { ESP_LOGW(TAG, "malformed gamebook.json"); return ESP_ERR_INVALID_ARG; }
s_passages = cJSON_GetObjectItem(s_root, "passages");
const cJSON *start = cJSON_GetObjectItem(s_root, "start");
const cJSON *title = cJSON_GetObjectItem(s_root, "title");
if (!cJSON_IsObject(s_passages) || !cJSON_IsString(start)) {
ESP_LOGW(TAG, "gamebook.json missing passages/start");
cJSON_Delete(s_root); s_root = NULL; s_passages = NULL;
return ESP_ERR_INVALID_ARG;
}
snprintf(s_title, sizeof(s_title), "%s",
cJSON_IsString(title) ? title->valuestring : "");
s_active = true;
ESP_LOGI(TAG, "start \"%s\" @ '%s'", s_title, start->valuestring);
enter_passage(start->valuestring);
return ESP_OK;
}
void gamebook_stop(void)
{
if (!s_active && !s_root) return;
s_active = false;
media_manager_stop();
display_ui_gamebook_hide();
if (s_root) { cJSON_Delete(s_root); s_root = NULL; }
s_passages = NULL;
s_current[0] = '\0';
ESP_LOGI(TAG, "stopped");
}
void gamebook_init(void)
{
display_ui_set_key_hook(gamebook_key_hook);
ESP_LOGI(TAG, "ready (key hook installed)");
}
@@ -0,0 +1,42 @@
#pragma once
// gamebook — "livre dont vous êtes le héros" mode for the Freenove master.
//
// Reads /sdcard/gamebook/gamebook.json (built by tools/gamebook/build_gamebook.py),
// plays each passage's WAV narration from the SD via media_manager, shows the
// passage title + choice menu on the display, and navigates with the 5-way pad
// (it installs a display_ui key hook so it owns the buttons while active).
// Fully local: no model, no gateway.
//
// JSON shape:
// { "title": "...", "start": "intro",
// "passages": { "intro": { "wav": "intro.wav", "screen": "...",
// "menu": "[OK] ... | [<>] ...",
// "choices": [ {"key": 1, "goto": "machine"}, ... ] },
// ... } }
// key codes match the 5-way pad: 1=SELECT 2=DOWN 3=MENU 4=LEFT/RIGHT 5=UP.
// MENU (3) always quits the gamebook.
#include "esp_err.h"
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
// Install the display_ui key hook. Call once at boot, after display_ui_init().
void gamebook_init(void);
// Load /sdcard/gamebook/gamebook.json and enter the start passage (show text +
// play its WAV). Returns ESP_ERR_* if the SD/JSON is missing or malformed.
esp_err_t gamebook_start(void);
// Leave gamebook mode: stop playback, release the JSON, hand the pad back to
// the shell. Idempotent.
void gamebook_stop(void);
// True while a gamebook is running (used by the key hook to claim the pad).
bool gamebook_active(void);
#ifdef __cplusplus
}
#endif
@@ -22,6 +22,7 @@ idf_component_register(
REQUIRES
media_manager
hints_client
esp_http_client
esp_timer
esp_system
nvs_flash
@@ -169,6 +169,16 @@ esp_err_t npc_engine_trigger_cue(const char *cue_id);
// and primes the stuck timer.
esp_err_t npc_engine_set_step(uint8_t step_id, uint32_t expected_duration_ms);
// Same bridge as npc_engine_set_step(), but keyed by the canonical scene
// string id (e.g. "SCENE_WARNING") rather than its numeric index. Resolves
// the id against the engine's internal scene table and forwards to
// npc_engine_set_step(). Lets the game endpoint sync the NPC/hints engine
// (and the voice gateway) straight from the scenario IR's `scene_id`.
// Returns ESP_ERR_NOT_FOUND when the id matches no known scene (no notify
// is sent in that case), ESP_ERR_INVALID_ARG on a NULL/empty id.
esp_err_t npc_engine_set_scene_by_id(const char *scene_id,
uint32_t expected_duration_ms);
// Request a hint for `puzzle_id` at escalation `level` (0..3). The engine
// invokes `cb` with the resulting text. The current implementation is a
// LOCAL STUB: it returns a hardcoded French placeholder synchronously.
@@ -184,6 +194,12 @@ const npc_state_t *npc_engine_state(void);
// Thin wrapper kept here to give callers a single npc_engine_* surface.
esp_err_t npc_engine_set_group_profile(const char *profile);
// Point the engine at the voice gateway (tools/zacus-gateway). On each scene
// change the engine POSTs the active SCENE_* to {base_url}/game/step?scene=… so
// the phone NPCs disguise that scene's hint. Best-effort; pass NULL/"" to
// disable. `token` is sent as a Bearer header (gateway require_token).
void npc_engine_set_gateway(const char *base_url, const char *token);
// Write the active puzzle id (e.g. "SCENE_LA_DETECTOR") into `out`,
// truncated to `cap` bytes (NUL-terminated). Falls back to "SCENE_NPC"
// when no scene is active or the engine is not initialised so callers
@@ -17,16 +17,78 @@
#include "npc_engine.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "esp_err.h"
#include "esp_http_client.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "media_manager.h"
#include "hints_client.h"
static const char *TAG = "npc_engine";
// Voice gateway (tools/zacus-gateway) — we push the active SCENE_* to it at each
// scene change so the phone NPCs disguise THIS scene's hint. Best-effort, set by
// npc_engine_set_gateway(); empty = disabled (no-op, e.g. CI / dry runs).
static char s_gw_url[128] = {0};
static char s_gw_token[80] = {0};
void npc_engine_set_gateway(const char *base_url, const char *token) {
if (base_url) snprintf(s_gw_url, sizeof(s_gw_url), "%s", base_url);
if (token) snprintf(s_gw_token, sizeof(s_gw_token), "%s", token);
}
// One-shot worker: POST {gateway}/game/step?scene=<scene> then self-delete.
// Runs on its own task so a slow/unreachable gateway NEVER stalls the scene
// transition (the game must not lag 2 s on every scene change if the gateway is
// offline). `arg` is a heap copy of the scene, freed here. Best-effort.
static void gw_notify_task(void *arg) {
char *scene = (char *) arg;
char url[256];
snprintf(url, sizeof(url), "%s/game/step?scene=%s", s_gw_url, scene);
esp_http_client_config_t cfg = {
.url = url,
.method = HTTP_METHOD_POST,
.timeout_ms = 2000,
};
esp_http_client_handle_t cli = esp_http_client_init(&cfg);
if (cli) {
if (s_gw_token[0]) {
char auth[112];
snprintf(auth, sizeof(auth), "Bearer %s", s_gw_token);
esp_http_client_set_header(cli, "Authorization", auth);
}
esp_err_t err = esp_http_client_perform(cli);
if (err != ESP_OK) {
ESP_LOGW(TAG, "gateway /game/step notify failed: %s", esp_err_to_name(err));
} else {
ESP_LOGI(TAG, "gateway scene -> %s (HTTP %d)", scene,
esp_http_client_get_status_code(cli));
}
esp_http_client_cleanup(cli);
}
free(scene);
vTaskDelete(NULL);
}
// Fire-and-forget the gateway scene notification on a detached task so a scene
// change is never blocked by the network. No-op if no gateway configured.
static void notify_gateway_scene(const char *scene) {
if (!s_gw_url[0] || !scene || !scene[0]) return;
size_t n = strlen(scene) + 1;
char *copy = malloc(n);
if (!copy) return;
memcpy(copy, scene, n);
if (xTaskCreate(gw_notify_task, "gw_notify", 4096, copy, 3, NULL) != pdPASS) {
ESP_LOGW(TAG, "gw_notify task spawn failed");
free(copy);
}
}
// ─── Core: scene/trigger/mood lookup tables (verbatim Arduino) ──────────────
static const char *const kSceneIds[] = {
@@ -372,10 +434,25 @@ esp_err_t npc_engine_set_step(uint8_t step_id, uint32_t expected_duration_ms) {
(unsigned) prev_scene, (unsigned) step_id);
}
(void) hints_client_puzzle_start(puzzle_id);
// Keep the voice gateway in sync so the phone NPCs hint on THIS scene.
notify_gateway_scene(puzzle_id);
}
return ESP_OK;
}
esp_err_t npc_engine_set_scene_by_id(const char *scene_id,
uint32_t expected_duration_ms) {
if (scene_id == NULL || scene_id[0] == '\0') return ESP_ERR_INVALID_ARG;
for (uint8_t i = 0; i < kSceneCount; i++) {
if (strcmp(scene_id, kSceneIds[i]) == 0) {
return npc_engine_set_step(i, expected_duration_ms);
}
}
ESP_LOGW(TAG, "set_scene_by_id: unknown scene \"%s\" (no kSceneIds match)",
scene_id);
return ESP_ERR_NOT_FOUND;
}
esp_err_t npc_engine_set_group_profile(const char *profile) {
// Thin pass-through. hints_client validates the value and logs the
// outcome. Kept on npc_engine so the rest of the firmware doesn't
+3 -2
View File
@@ -310,8 +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 = 20; // ota + voice_hook + game (incl. /game/step,
// /game/puzzle_state, /game/file, relay)
config.max_uri_handlers = 22; // ota + voice_hook + game (incl. /game/step,
// /game/puzzle_state, /game/file, relay,
// /game/gamebook)
// + headroom
config.uri_match_fn = httpd_uri_match_wildcard;
config.stack_size = 8192;
+1
View File
@@ -23,6 +23,7 @@ idf_component_register(
esp_event
espressif__mdns
display_ui
gamebook
puzzle_state
p7_coffre
p5_morse
+12
View File
@@ -55,6 +55,7 @@
#include "mic_broker.h"
#include "board_pins_mediakit.h"
#include "display_ui.h"
#include "gamebook.h"
#include "p7_coffre.h"
#include "p5_morse.h"
#include "p6_nfc.h"
@@ -63,6 +64,12 @@
// this to NVS so the field operator can repoint the firmware without a flash.
#define ZACUS_HINTS_BASE_URL "http://192.168.0.150:8302"
// Voice gateway (tools/zacus-gateway) — the engine POSTs each scene change to
// {URL}/game/step?scene=SCENE_* so the phone NPCs disguise that scene's hint.
// Hardcoded for now like the URLs above — moves to NVS in the same follow-up.
#define ZACUS_GATEWAY_BASE_URL "http://192.168.0.175:8401"
#define ZACUS_GATEWAY_TOKEN "testtoken"
// Slice 7: voice-bridge WebSocket on the MacStudio (Tailscale address).
// Hardcoded here for the same reason as ZACUS_HINTS_BASE_URL — moves to
// NVS in a follow-up slice. The bridge endpoint is documented in
@@ -430,6 +437,8 @@ void app_main(void) {
if (btn_err != ESP_OK) {
ESP_LOGW(TAG, "buttons_input_init: %s", esp_err_to_name(btn_err));
}
// Gamebook mode owns the 5-way pad when started (POST /game/gamebook).
gamebook_init();
}
bool sta_ok = wifi_bring_up();
@@ -530,6 +539,9 @@ void app_main(void) {
ESP_LOGE(TAG, "npc_engine_init failed: %s",
esp_err_to_name(npc_err));
}
// Push each scene change to the voice gateway so the phone NPCs
// disguise the CURRENT scene's hint (best-effort, see main.c URL).
npc_engine_set_gateway(ZACUS_GATEWAY_BASE_URL, ZACUS_GATEWAY_TOKEN);
// Slice 5: bring up the hints HTTP client (so npc_engine can
// route hint requests through the real backend) and the voice
+1 -1
View File
@@ -31,7 +31,7 @@ menu "PLIP Voice Configuration"
config PLIP_SPEAKER_VOLUME
int "Default Speaker Volume (0-100)"
default 80
default 98
range 0 100
help
Default speaker output volume at boot.
+100 -38
View File
@@ -18,6 +18,7 @@
#include "board_config.h"
#include "es8388.h"
#include "phone.h"
#include "slic.h"
#include <inttypes.h>
#include <math.h>
@@ -136,6 +137,12 @@ static void ensure_sd_mounted(void)
}
}
bool audio_ensure_sd(void)
{
ensure_sd_mounted();
return s_sd_mounted;
}
/* ── WAV parser ──────────────────────────────────────────────────────────── */
static esp_err_t parse_wav_header(const uint8_t *buf, size_t len, wav_info_t *out)
@@ -376,8 +383,13 @@ static void audio_worker_task(void *arg)
xTaskCreate(ring_task, "ring", 4096, NULL, 4, NULL);
break;
case CMD_PLAY: {
/* Gate playback on hook state: no sound if handset is on-hook. */
if (!phone_is_offhook()) {
/* Gate playback on the REAL hook line (raw SLIC SHK), not phone.c's
* debounced flag. The debounced flag lags or misses the pickup while
* the bell is ringing, so an incoming-call greeting enqueued right
* after pickup would be dropped here ("on-hook") even though the
* handset is physically up. The raw SHK reads the pickup cleanly, and
* is the single source of truth for hook state. */
if (!slic_is_offhook()) {
ESP_LOGI(TAG, "play ignored: on-hook (handset down)");
break;
}
@@ -593,63 +605,113 @@ int audio_capture_wav(uint8_t *out, size_t out_max, int max_ms, int silence_ms)
const int max_frames = (max_ms / 20);
const int silence_frames = (silence_ms / 20);
/* VAD thresholds (raw S16: ~1% FS = ~328 for speech onset, ~0.3% for end) */
const int32_t vad_onset_rms_sq = 328 * 328; /* ~1% FS² */
const int32_t vad_silence_rms_sq = 100 * 100; /* ~0.3% FS² */
/* Calibrated to the QUIET handset voice (DC-blocked: body 0.16-0.8 % FS RMS,
* loud syllables ~2-4 %, noise floor ~0.3 %). The old onset (1.4 %) NEVER
* triggered → "no sustained voice"; the old silence (0.6 %) sat ABOVE the
* body → cut mid-sentence. Now: onset catches a real syllable just above the
* floor (3-frame confirm guards clicks); silence sits just above the floor so
* the turn ends only on a true pause, holding the whole sentence. */
const int32_t vad_onset_rms_sq = 230 * 230; /* ~0.70% FS² — quiet-voice onset */
const int32_t vad_silence_rms_sq = 110 * 110; /* ~0.34% FS² — just above noise floor */
bool voice_started = false;
int silent_frames = 0;
int total_frames = 0;
/* One-pole DC blocker (high-pass ~80 Hz) applied to the captured mono.
* The SLIC/handset path carries a huge DC/sub-audio offset (~80 % FS,
* measured). Left in, it (a) swamps the faint voice and (b) keeps the VAD
* RMS permanently above the silence threshold, so the capture never ends
* and always runs the full max_ms — and the gateway then sees only a DC
* transient, transcribing empty. Removing it lets the VAD track the real
* speech envelope (onset/silence work) and hands the gateway a clean,
* voice-dominated signal. Telephone band (300-3400 Hz) is untouched.
* y[n] = x[n] - x[n-1] + R*y[n-1]; R=0.97 → cutoff ~80 Hz at 16 kHz. */
float dc_x1 = 0.0f, dc_y1 = 0.0f;
const float dc_R = 0.97f;
ESP_LOGI(TAG, "capture: RX enabled, max_ms=%d silence_ms=%d", max_ms, silence_ms);
for (int f = 0; f < max_frames && pcm_written + frame_out_bytes <= pcm_max; f++) {
/* ── Phase A: wait for SUSTAINED voice before committing to a capture ─────
* Only start recording when the caller actually speaks. We require the
* DC-blocked RMS to stay above the onset threshold for ONSET_CONFIRM
* consecutive frames (~60 ms) — a single loud frame is rejected as a
* transient (e.g. the click when the PA mutes just before capture). Audio
* is discarded during this phase. If no sustained voice arrives within
* max_ms we return an empty WAV so the caller posts nothing (the NPC must
* not "reply" to silence). The listen loop simply calls us again. */
const int ONSET_CONFIRM = 3;
int onset_run = 0;
bool got_onset = false;
for (int f = 0; f < max_frames; f++) {
size_t bytes_read = 0;
ret = i2s_channel_read(s_mic_handle, rx_buf, frame_in_bytes,
&bytes_read, pdMS_TO_TICKS(100));
if (ret != ESP_OK || bytes_read == 0) {
ESP_LOGW(TAG, "capture: read error f=%d: %s", f, esp_err_to_name(ret));
continue;
}
/* Downmix stereo → mono, compute RMS². */
int16_t *out_frame = (int16_t *)(pcm_out + pcm_written);
int64_t rms_sq = 0;
int n_stereo = (int)(bytes_read / (2 * sizeof(int16_t)));
if (ret != ESP_OK || bytes_read == 0) continue;
int64_t rms_sq = 0;
int n_stereo = (int)(bytes_read / (2 * sizeof(int16_t)));
for (int i = 0; i < n_stereo; i++) {
int32_t l = rx_buf[i * 2];
int32_t r = rx_buf[i * 2 + 1];
int16_t mono = (int16_t)((l + r) / 2);
out_frame[i] = mono;
rms_sq += (int64_t)mono * mono;
float xf = (float)((rx_buf[i * 2] + rx_buf[i * 2 + 1]) / 2);
float yf = xf - dc_x1 + dc_R * dc_y1; /* DC-blocking high-pass */
dc_x1 = xf; dc_y1 = yf;
int32_t s = (int32_t)yf;
rms_sq += (int64_t)s * s;
}
rms_sq /= (n_stereo > 0 ? n_stereo : 1);
/* VAD logic. */
if (!voice_started) {
if (rms_sq >= vad_onset_rms_sq) {
voice_started = true;
silent_frames = 0;
ESP_LOGI(TAG, "capture: voice onset at frame %d (rms²=%"PRId64")", f, rms_sq);
if (rms_sq >= vad_onset_rms_sq) {
if (++onset_run >= ONSET_CONFIRM) {
got_onset = true;
ESP_LOGI(TAG, "capture: sustained voice onset at frame %d (rms²=%"PRId64")",
f, rms_sq);
break;
}
/* Before voice onset: still accumulate (to avoid clipping onset).
* But don't count towards silence timeout yet. */
} else {
onset_run = 0;
}
}
if (got_onset) {
/* ── Phase B: record the utterance until silence_ms of silence ──────── */
for (int f = 0; f < max_frames && pcm_written + frame_out_bytes <= pcm_max; f++) {
size_t bytes_read = 0;
ret = i2s_channel_read(s_mic_handle, rx_buf, frame_in_bytes,
&bytes_read, pdMS_TO_TICKS(100));
if (ret != ESP_OK || bytes_read == 0) {
ESP_LOGW(TAG, "capture: read error f=%d: %s", f, esp_err_to_name(ret));
continue;
}
/* Downmix stereo → mono (DC-blocked), compute RMS². */
int16_t *out_frame = (int16_t *)(pcm_out + pcm_written);
int64_t rms_sq = 0;
int n_stereo = (int)(bytes_read / (2 * sizeof(int16_t)));
for (int i = 0; i < n_stereo; i++) {
float xf = (float)((rx_buf[i * 2] + rx_buf[i * 2 + 1]) / 2);
float yf = xf - dc_x1 + dc_R * dc_y1;
dc_x1 = xf; dc_y1 = yf;
if (yf > 32767.0f) yf = 32767.0f;
else if (yf < -32768.0f) yf = -32768.0f;
int16_t mono = (int16_t)yf;
out_frame[i] = mono;
rms_sq += (int64_t)mono * mono;
}
rms_sq /= (n_stereo > 0 ? n_stereo : 1);
pcm_written += (size_t)n_stereo * sizeof(int16_t);
total_frames = f + 1;
if (rms_sq < vad_silence_rms_sq) {
silent_frames++;
if (silent_frames >= silence_frames) {
total_frames = f + 1;
ESP_LOGI(TAG, "capture: VAD end (silence %d frames at f=%d)", silent_frames, f);
pcm_written += (size_t)n_stereo * sizeof(int16_t);
if (++silent_frames >= silence_frames) {
ESP_LOGI(TAG, "capture: VAD end (silence %d frames at f=%d)",
silent_frames, f);
break;
}
} else {
silent_frames = 0;
}
}
pcm_written += (size_t)n_stereo * sizeof(int16_t);
total_frames = f + 1;
} else {
ESP_LOGI(TAG, "capture: no sustained voice in %d ms — nothing to send", max_ms);
/* pcm_written stays 0 → empty WAV → caller skips posting. */
}
/* Leave RX enabled (full-duplex, as at boot). Disabling it here would make
+6
View File
@@ -39,6 +39,12 @@ esp_err_t audio_play_async(const char *path);
/* Stop current playback immediately. */
esp_err_t audio_stop(void);
/* Mount the microSD card at /sdcard on demand (best-effort, idempotent) and
* report whether it is mounted. Unlike playback, this does NOT depend on the
* hook state — it lets the REST layer stage a voice pack onto the SD while the
* handset is down. Returns true if /sdcard is usable. */
bool audio_ensure_sd(void);
/* True while the audio worker is playing a clip (tone/WAV). The conversation
* LISTEN loop polls this to stay half-duplex: never capture while playing
* (avoids the earpiece→mic feedback that saturated the line). */
+137 -17
View File
@@ -21,6 +21,7 @@
#include "tones.h"
#include "audio.h"
#include "phone.h"
#include "slic.h"
#include "turn_client.h"
#if CONFIG_PLIP_DIAL_DTMF
#include "dtmf.h"
@@ -46,7 +47,9 @@
#define CAPTURE_MAX_IRAM (128 * 1024)
#define CAPTURE_MAX_MS_PSRAM 8000
#define CAPTURE_MAX_MS_IRAM 4000
#define CAPTURE_SILENCE_MS 800
#define CAPTURE_SILENCE_MS 900 /* end-of-speech: needs to ride through brief
* mid-sentence pauses so a whole phrase is
* captured (quiet handset voice). 900 ms. */
#define REPLY_POLL_MS 200 /* interval for checking hook during playback */
#define REPLY_PLAYBACK_EXTRA_MS 500 /* safety margin added to computed WAV duration */
#define BETWEEN_TURNS_MS 300 /* short pause between capture rounds */
@@ -81,6 +84,17 @@ static char s_sid[32] = {0};
* NOT re-read dialer_current() for the greeting/reply — that polluted number
* would 404 at the gateway. Capture the clean routed number here once. */
static char s_number[16] = {0};
/* Scene this call hints on (e.g. "SCENE_WARNING"), locked at pickup like the
* number. Empty for player-dialled calls. Drives the local SD hint clip. */
static char s_scene[40] = {0};
/* Incoming-call mode: an NPC "calls" the phone. conversation_arm_incoming()
* stores the caller's persona number (and optional scene) and rings; when the
* handset is then picked up from IDLE, we skip the outgoing dialtone/dial/
* ringback and go straight to GREET with this number (the NPC speaks first). */
static char s_incoming_number[16] = {0};
static char s_incoming_scene[40] = {0};
static volatile bool s_incoming_armed = false;
/* Known numbers: ringback when dialed */
static const char *KNOWN[] = {
@@ -95,6 +109,20 @@ static bool is_known(const char *num)
return false;
}
/* "SCENE_WARNING" → "warning": strip the SCENE_ prefix and lowercase, matching
* the SD pack filenames from tools/tts/generate_plip_sd_pack.py. */
static void scene_slug(const char *scene, char *out, size_t cap)
{
const char *p = scene;
if (strncmp(p, "SCENE_", 6) == 0) p += 6;
size_t i = 0;
for (; p[i] && i + 1 < cap; i++) {
char c = p[i];
out[i] = (c >= 'A' && c <= 'Z') ? (char)(c - 'A' + 'a') : c;
}
out[i] = '\0';
}
static void go_idle(void)
{
tones_stop();
@@ -104,10 +132,35 @@ static void go_idle(void)
#if CONFIG_PLIP_DIAL_DTMF
dtmf_stop();
#endif
s_scene[0] = '\0'; /* clear scripted-call scene so a later dial-out call
* doesn't inherit it (CONNECTED would skip listening). */
s_state = STATE_IDLE;
ESP_LOGI(TAG, "-> IDLE");
}
/* Begin an answered INCOMING call: stop ringing, lock the persona number, and
* jump straight to GREET (the NPC speaks first). Drives the conversation's own
* s_offhook directly because phone.c's debounced GPIO detection is unreliable
* during ringing — we trust the SLIC's raw off-hook reading instead. */
static void enter_incoming_greet(void)
{
s_incoming_armed = false;
phone_ring_stop();
slic_ring_stop(); /* belt-and-suspenders: kill the physical bell directly in
* case phone.c's s_ringing flag desynced from slic.c's
* (otherwise phone_ring_stop early-returns and the bell
* keeps ringing through the answered call). */
tones_stop();
dialer_reset();
s_offhook = true;
snprintf(s_number, sizeof(s_number), "%s", s_incoming_number);
snprintf(s_scene, sizeof(s_scene), "%s", s_incoming_scene);
snprintf(s_sid, sizeof(s_sid), "%lld", (long long)esp_timer_get_time());
audio_pa_set(true);
s_state = STATE_GREET;
ESP_LOGI(TAG, "INCOMING pickup -> GREET num=%s sid=%s", s_number, s_sid);
}
static void conv_task(void *arg)
{
(void)arg;
@@ -127,15 +180,21 @@ static void conv_task(void *arg)
}
continue;
} else {
/* Off-hook: start dial tone if we were idle */
/* Off-hook from idle */
if (s_state == STATE_IDLE) {
dialer_reset();
tones_dialtone_start();
if (s_incoming_armed) {
/* INCOMING call answered (phone.c detected the edge). */
enter_incoming_greet();
} else {
/* Outgoing call: dial tone, wait for digits. */
dialer_reset();
tones_dialtone_start();
#if CONFIG_PLIP_DIAL_DTMF
dtmf_start();
dtmf_start();
#endif
s_state = STATE_DIALTONE;
ESP_LOGI(TAG, "off-hook -> DIALTONE");
s_state = STATE_DIALTONE;
ESP_LOGI(TAG, "off-hook -> DIALTONE");
}
}
continue;
}
@@ -144,7 +203,13 @@ static void conv_task(void *arg)
/* State machine polling */
switch (s_state) {
case STATE_IDLE:
break; /* nothing to do */
/* Incoming-call pickup: phone.c's debounced edge detection is
* unreliable while the bell rings, so poll the SLIC's raw off-hook
* line directly — it reads the pickup cleanly mid-ring. */
if (s_incoming_armed && slic_is_offhook()) {
enter_incoming_greet();
}
break;
case STATE_DIALTONE:
if (!s_offhook) {
@@ -213,18 +278,57 @@ static void conv_task(void *arg)
go_idle();
break;
}
/* Fetch greeting WAV from gateway and enqueue playback */
if (turn_client_greeting(s_sid, s_number,
"/spiffs/turn.wav")) {
audio_play_async("/spiffs/turn.wav");
} else {
ESP_LOGW(TAG, "turn_client_greeting failed — proceeding silent");
/* Prefer the local SD voice pack (no gateway, no model in RAM):
* play /sdcard/voice/greet_<number>.wav when present. Fall back to
* the live gateway greeting only when the pack has no clip for this
* NPC number (see tools/tts/generate_plip_sd_pack.py). */
{
char sd_greet[64];
snprintf(sd_greet, sizeof(sd_greet),
"/sdcard/voice/greet_%s.wav", s_number);
FILE *tf = NULL;
if (audio_ensure_sd() && (tf = fopen(sd_greet, "rb")) != NULL) {
fclose(tf);
audio_play_async(sd_greet);
ESP_LOGI(TAG, "GREET: local SD pack %s", sd_greet);
} else if (turn_client_greeting(s_sid, s_number,
"/spiffs/turn.wav")) {
audio_play_async("/spiffs/turn.wav");
} else {
ESP_LOGW(TAG, "no SD clip + gateway greeting failed — silent");
}
}
/* Scripted offline call: chain this scene's hint from the SD pack
* right after the greeting (the audio queue plays them back-to-back).
* Fully local — no gateway, no model. */
if (s_scene[0] != '\0' && audio_ensure_sd()) {
char slug[40], hint[80];
scene_slug(s_scene, slug, sizeof(slug));
snprintf(hint, sizeof(hint),
"/sdcard/voice/hint_%s_l1_0.wav", slug);
FILE *hf = fopen(hint, "rb");
if (hf != NULL) {
fclose(hf);
audio_play_async(hint);
ESP_LOGI(TAG, "GREET: chained SD hint %s", hint);
} else {
ESP_LOGI(TAG, "GREET: no SD hint for scene %s (%s)",
s_scene, hint);
}
}
s_state = STATE_CONNECTED;
ESP_LOGI(TAG, "-> CONNECTED");
break;
case STATE_CONNECTED:
/* Scripted offline call (greeting + SD hint already queued): there's
* no live two-way conversation to run — just let the clips play out
* and wait for the player to hang up. Skips the gateway/model listen
* loop entirely. The global on-hook check returns us to IDLE. */
if (s_scene[0] != '\0') {
vTaskDelay(pdMS_TO_TICKS(REPLY_POLL_MS));
break;
}
#if CONFIG_PLIP_VOICE_REPLY
/*
* Stage 3 — LISTEN loop.
@@ -268,14 +372,18 @@ static void conv_task(void *arg)
* the mic. Never capture while anything is playing, or the
* playback feeds back and the line saturates. Wait for the
* greeting/filler/reply to finish, then let the line settle. */
vTaskDelay(pdMS_TO_TICKS(250)); /* let a just-queued clip start */
vTaskDelay(pdMS_TO_TICKS(120)); /* let a just-queued clip start (was 250) */
while (s_offhook && audio_is_playing()) {
vTaskDelay(pdMS_TO_TICKS(50));
}
if (!s_offhook) break;
vTaskDelay(pdMS_TO_TICKS(200)); /* line settle after playback */
vTaskDelay(pdMS_TO_TICKS(200)); /* let the I2S DMA tail drain */
/* Capture player utterance — nothing is playing now. */
/* Capture the caller utterance with the PA LEFT ON. The loop
* already waits for any playback to finish above, so nothing
* is driving the earpiece during capture — there is no echo to
* mute. Cutting the PA here was found to collapse the mic level
* (captures came back near-silent / DC only), so keep it on. */
int n = audio_capture_wav(cap_buf, cap_max,
cap_ms, CAPTURE_SILENCE_MS);
if (!s_offhook) break; /* hung up during capture */
@@ -355,3 +463,15 @@ void conversation_on_hook_change(bool offhook)
s_offhook = offhook;
s_hook_changed = true;
}
void conversation_arm_incoming(const char *number, const char *scene)
{
snprintf(s_incoming_number, sizeof(s_incoming_number), "%s",
(number && number[0]) ? number : "17");
snprintf(s_incoming_scene, sizeof(s_incoming_scene), "%s",
(scene && scene[0]) ? scene : "");
s_incoming_armed = true;
phone_ring_start();
ESP_LOGI(TAG, "incoming call armed (num=%s scene=%s) — ringing until pickup",
s_incoming_number, s_incoming_scene[0] ? s_incoming_scene : "-");
}
+8
View File
@@ -3,3 +3,11 @@
void conversation_init(void);
void conversation_on_hook_change(bool offhook);
/* Arm an INCOMING call from NPC persona `number` and start ringing. When the
* handset is picked up from idle, the conversation jumps straight to the
* greeting (persona speaks first) instead of the outgoing dial-tone path.
* `scene` (optional, e.g. "SCENE_WARNING") makes the phone play that scene's
* hint from its local SD pack right after the greeting; pass NULL/"" for a
* greeting-only call. */
void conversation_arm_incoming(const char *number, const char *scene);
+18 -1
View File
@@ -87,6 +87,11 @@ esp_err_t es8388_read_reg(uint8_t reg, uint8_t *value)
pdMS_TO_TICKS(100));
}
esp_err_t es8388_write_reg(uint8_t reg, uint8_t value)
{
return i2c_write_reg(reg, value);
}
/* ── Public API ──────────────────────────────────────────────────────────── */
esp_err_t es8388_init(void)
@@ -137,6 +142,18 @@ esp_err_t es8388_init(void)
/* 3b. CONTROL2 (0x01): VROI=0, LPVrefBuf=0, normal operation. */
if (i2c_write_reg(ES8388_CHIP_CTL2, 0x50) != ESP_OK) return ESP_FAIL;
/* 3c. Internal DLL stabilisation for LOW sample rates (16 kHz). UNDOCUMENTED
* registers 0x35/0x37/0x39 — the proven A252 driver (hardware/projects/
* slic-phone Es8388Driver.cpp) sets these to "disable internal DLL for low
* sample-rate stability". WITHOUT them the ADC clock domain is unstable at
* 16 kHz and the ADC outputs a frozen DC value (capture = flat, no AC) even
* though analog signal is present on the LIN pin — exactly our symptom. The
* DAC tolerates it, which is why playback worked but capture didn't. They
* must be set here in the boot sequence (a live poke can't re-lock the DLL). */
if (i2c_write_reg(0x35, 0xA0) != ESP_OK) return ESP_FAIL;
if (i2c_write_reg(0x37, 0xD0) != ESP_OK) return ESP_FAIL;
if (i2c_write_reg(0x39, 0xD0) != ESP_OK) return ESP_FAIL;
/* 4. Clock: MCLK divider = 256*Fs, DAC SRC = MCLK. */
if (i2c_write_reg(0x08, 0x00) != ESP_OK) return ESP_FAIL; /* MASTERMODE = slave */
@@ -151,7 +168,7 @@ esp_err_t es8388_init(void)
* - ADCCONTROL4 (0x0C) = 0x0C: I2S Philips 16-bit word length
* - ADCCONTROL5 (0x0D) = 0x02: ADCFsMode SINGLE SPEED RATIO=256 (16kHz@MCLK 4.096MHz)
* - ADCCONTROL8/9 (0x10/0x11) = 0x00: ADC digital volume 0dB */
if (i2c_write_reg(ES8388_ADC_CTL1, 0x44) != ESP_OK) return ESP_FAIL; /* MIC PGA +12dB L+R (was +24dB: too hot for a close handset mic → clipping/feedback) */
if (i2c_write_reg(ES8388_ADC_CTL1, 0x88) != ESP_OK) return ESP_FAIL; /* MIC PGA +24dB L+R — the SLIC handset mic is QUIET (~2-5% FS at +12dB); +24dB lifts speech above the capture VAD onset. The old "+24dB too hot" note was an artifact of the broken ADC (pre-DLL-fix); measured clean at ~5% peak now. */
/* ADCCONTROL2 (0x0A): input select. The K50835F SLIC handset transmit audio is
* wired to LIN2/RIN2 on this bench — PROVEN: speech captured (ACrms 196, crest 10.3)
* on 0x50, vs DC-only floating offset on 0x00 (LIN1). */
+4
View File
@@ -40,6 +40,10 @@ esp_err_t es8388_mute(bool mute);
/* Read a single ES8388 register for diagnostic purposes. */
esp_err_t es8388_read_reg(uint8_t reg, uint8_t *value);
/* Write a single ES8388 register for diagnostic purposes (bench input/gain
* sweeps via /debug/es8388?reg=&val= — find which input the SLIC feeds). */
esp_err_t es8388_write_reg(uint8_t reg, uint8_t value);
#ifdef __cplusplus
}
#endif
+213 -34
View File
@@ -22,10 +22,13 @@
#include <string.h>
#include <stdio.h>
#include <errno.h>
#include <sys/stat.h>
#include "audio.h"
#include "tones.h"
#include "cmd_exec.h"
#include "phone.h"
#include "conversation.h"
#include "es8388.h"
#include "slic.h"
#include "board_config.h"
@@ -51,7 +54,8 @@
#define SPIFFS_LABEL "storage"
#define SPIFFS_BASE "/spiffs"
#define MAX_BODY (256 * 1024)
#define MAX_BODY (512 * 1024) /* streamed in 2 KB chunks → RAM-safe; raised
so full-length say() hint WAVs fit on SD */
static volatile bool s_connected = false;
static httpd_handle_t s_httpd = NULL;
@@ -83,10 +87,13 @@ static void wifi_event_handler(void *arg, esp_event_base_t base,
if (base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) {
esp_wifi_connect();
} else if (base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
if (!s_connected) return; /* still in initial connect loop */
ESP_LOGW(TAG, "WiFi disconnected, reconnecting...");
/* Retry on EVERY disconnect, including failures during the initial boot
* attempt: on a mesh the first association frequently fails (wrong node
* picked / channel roam) and must be retried, otherwise the boot connect
* just times out. Previously the initial loop gave up on first failure. */
s_connected = false;
vTaskDelay(pdMS_TO_TICKS(2000));
ESP_LOGW(TAG, "WiFi disconnected/assoc failed, reconnecting...");
vTaskDelay(pdMS_TO_TICKS(1500));
esp_wifi_connect();
} else if (base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
ip_event_got_ip_t *ev = (ip_event_got_ip_t *)data;
@@ -135,9 +142,17 @@ static esp_err_t handle_status(httpd_req_t *req)
esp_netif_t *sta = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
if (sta) esp_netif_get_ip_info(sta, &ip_info);
/* Report REAL state (this used to hardcode playing/off_hook to false, which
* was badly misleading during bring-up). off_hook = firmware debounced view
* (phone_is_offhook); shk = raw SLIC line, the single source of truth that
* gates audio playback — sound routes to the handset only when shk is true. */
snprintf(buf, sizeof(buf),
"{\"board\":\"plip\",\"ip\":\"" IPSTR "\",\"playing\":false,\"off_hook\":false}",
IP2STR(&ip_info.ip));
"{\"board\":\"plip\",\"ip\":\"" IPSTR "\",\"playing\":%s,"
"\"off_hook\":%s,\"shk\":%s}",
IP2STR(&ip_info.ip),
audio_is_playing() ? "true" : "false",
phone_is_offhook() ? "true" : "false",
slic_is_offhook() ? "true" : "false");
return send_json(req, "200 OK", buf);
}
@@ -196,14 +211,39 @@ static esp_err_t handle_file_post(httpd_req_t *req)
if (req->content_len <= 0 || req->content_len > MAX_BODY)
return send_json(req, "413 Payload Too Large", "{\"error\":\"too large\"}");
ensure_spiffs();
/* Route to the microSD when the path is sd-prefixed ("sd/…", "sdcard/…"
* or "/sdcard/…"); otherwise stage in SPIFFS (default). The SD is mounted
* on demand here so a voice pack can be staged without an off-hook play. */
const char *base;
const char *rel = fpath;
bool to_sd = false;
if (strncmp(fpath, "sd/", 3) == 0) { rel = fpath + 3; to_sd = true; }
else if (strncmp(fpath, "sdcard/", 7) == 0) { rel = fpath + 7; to_sd = true; }
else if (strncmp(fpath, "/sdcard/", 8) == 0) { rel = fpath + 8; to_sd = true; }
/* Build full SPIFFS path. */
char full[160];
if (fpath[0] == '/') {
snprintf(full, sizeof(full), "%s%s", SPIFFS_BASE, fpath);
if (to_sd) {
if (!audio_ensure_sd())
return send_json(req, "503 Service Unavailable", "{\"error\":\"no sd card\"}");
base = PLIP_SD_MOUNT; /* "/sdcard" */
} else {
snprintf(full, sizeof(full), "%s/%s", SPIFFS_BASE, fpath);
ensure_spiffs();
base = SPIFFS_BASE;
}
while (*rel == '/') rel++; /* avoid a double slash when joining */
char full[192];
snprintf(full, sizeof(full), "%s/%s", base, rel);
/* Create the immediate parent dir (one level) so a pack can live in a
* subfolder like /sdcard/voice/<file>.wav — fopen won't create it. FAT
* supports mkdir; SPIFFS is flat so we only do this for the SD path. */
if (to_sd) {
char *slash = strrchr(full, '/');
if (slash && slash != full) {
*slash = '\0';
mkdir(full, 0775); /* best-effort; ignore EEXIST */
*slash = '/';
}
}
FILE *f = fopen(full, "wb");
@@ -241,6 +281,44 @@ static esp_err_t handle_file_post(httpd_req_t *req)
return send_json(req, "200 OK", resp);
}
/* ── DELETE /game/file?path=... (remove a staged file; SD or SPIFFS) ─────── */
/* Same path routing as the POST handler (sd/ prefix → /sdcard, else /spiffs).
* Lets the host clean stale clips when regenerating the SD voice pack. */
static esp_err_t handle_file_delete(httpd_req_t *req)
{
char query[128] = {0}, fpath[128] = {0};
httpd_req_get_url_query_str(req, query, sizeof(query));
if (httpd_query_key_value(query, "path", fpath, sizeof(fpath)) != ESP_OK)
return send_json(req, "400 Bad Request", "{\"error\":\"missing path param\"}");
const char *base, *rel = fpath;
bool to_sd = false;
if (strncmp(fpath, "sd/", 3) == 0) { rel = fpath + 3; to_sd = true; }
else if (strncmp(fpath, "sdcard/", 7) == 0) { rel = fpath + 7; to_sd = true; }
else if (strncmp(fpath, "/sdcard/", 8) == 0) { rel = fpath + 8; to_sd = true; }
if (to_sd) {
if (!audio_ensure_sd())
return send_json(req, "503 Service Unavailable", "{\"error\":\"no sd card\"}");
base = PLIP_SD_MOUNT;
} else {
ensure_spiffs();
base = SPIFFS_BASE;
}
while (*rel == '/') rel++;
char full[192];
snprintf(full, sizeof(full), "%s/%s", base, rel);
if (remove(full) != 0) {
ESP_LOGW(TAG, "remove %s failed: errno=%d", full, errno);
return send_json(req, "404 Not Found", "{\"error\":\"remove failed\"}");
}
ESP_LOGI(TAG, "file removed: %s", full);
char resp[256];
snprintf(resp, sizeof(resp), "{\"status\":\"ok\",\"removed\":\"%s\"}", full);
return send_json(req, "200 OK", resp);
}
/* ── POST /voice/capture ──────────────────────────────────────────────────── */
#define CAP_MAX_MS_DEFAULT 5000
@@ -404,6 +482,38 @@ static esp_err_t handle_debug_regs(httpd_req_t *req)
return httpd_resp_send(req, buf, n);
}
/* ── GET /debug/es8388?reg=0x0A&val=0x00 (poke a codec register) ──────────────
* Bench tool to find which ADC input the SLIC handset mic feeds: sweep the
* input-select (ADCCONTROL2 = reg 0x0A) and PGA gain (ADCCONTROL1 = reg 0x09)
* live, then re-capture, without reflashing per experiment. reg/val accept
* decimal or 0x-hex. Returns the written value read back. */
static esp_err_t handle_debug_es8388(httpd_req_t *req)
{
char query[64] = {0};
httpd_req_get_url_query_str(req, query, sizeof(query));
char rs[12] = {0}, vs[12] = {0};
if (httpd_query_key_value(query, "reg", rs, sizeof(rs)) != ESP_OK)
return send_json(req, "400 Bad Request", "{\"error\":\"missing reg param\"}");
uint8_t reg = (uint8_t)strtol(rs, NULL, 0);
char resp[96];
if (httpd_query_key_value(query, "val", vs, sizeof(vs)) == ESP_OK) {
uint8_t val = (uint8_t)strtol(vs, NULL, 0);
esp_err_t err = es8388_write_reg(reg, val);
uint8_t rb = 0;
es8388_read_reg(reg, &rb);
ESP_LOGI(TAG, "debug/es8388: reg 0x%02X <- 0x%02X (readback 0x%02X, %s)",
reg, val, rb, esp_err_to_name(err));
snprintf(resp, sizeof(resp),
"{\"reg\":\"0x%02X\",\"wrote\":\"0x%02X\",\"readback\":\"0x%02X\",\"ok\":%s}",
reg, val, rb, err == ESP_OK ? "true" : "false");
} else {
uint8_t rb = 0;
es8388_read_reg(reg, &rb);
snprintf(resp, sizeof(resp), "{\"reg\":\"0x%02X\",\"value\":\"0x%02X\"}", reg, rb);
}
return send_json(req, "200 OK", resp);
}
/* ── POST /game/cmd (WiFi-direct CMD endpoint) ───────────────────────────── */
#define MAX_CMD_BYTES 512
@@ -436,9 +546,20 @@ static esp_err_t handle_cmd_post(httpd_req_t *req)
static esp_err_t handle_debug_ring(httpd_req_t *req)
{
phone_ring_start();
ESP_LOGI(TAG, "debug/ring: ring started");
return send_json(req, "200 OK", "{\"ok\":true,\"ringing\":true}");
/* /debug/ring[?number=NN] — arm an INCOMING call from persona NN (default
* 17) and ring. On pickup the conversation jumps straight to the greeting. */
char query[96] = {0}, number[16] = {0}, scene[40] = {0};
httpd_req_get_url_query_str(req, query, sizeof(query));
httpd_query_key_value(query, "number", number, sizeof(number));
httpd_query_key_value(query, "scene", scene, sizeof(scene));
/* scene (optional) lets the phone play that scene's hint from its SD pack
* after the greeting — fully offline, no model. */
conversation_arm_incoming(number, scene[0] ? scene : NULL);
char resp[64];
snprintf(resp, sizeof(resp), "{\"ok\":true,\"ringing\":true,\"incoming\":\"%s\"}",
number[0] ? number : "17");
ESP_LOGI(TAG, "debug/ring: incoming armed (num=%s), ringing", number[0] ? number : "17");
return send_json(req, "200 OK", resp);
}
/* ── GET /debug/ringstop (stop ring tone over HTTP) ─────────────────────── */
@@ -548,20 +669,59 @@ static esp_err_t handle_debug_dacvol(httpd_req_t *req)
return send_json(req, "200 OK", resp);
}
/* ── GET /debug/offhook?on=1 (force hook state, bypass flaky contact) ───────── */
static esp_err_t handle_debug_offhook(httpd_req_t *req)
/* ── GET /debug/miccap?ms=4000 (TEMP DIAG: raw fixed-duration mic capture) ───
* No VAD, DC-blocked, PA on, tones stopped. Lets us measure the true handset
* mic level while the caller speaks continuously, independent of VAD timing. */
static esp_err_t handle_debug_miccap(httpd_req_t *req)
{
char query[24] = {0};
char query[64] = {0};
httpd_req_get_url_query_str(req, query, sizeof(query));
char on[4] = {0};
int v = 1; /* default: force off-hook */
if (httpd_query_key_value(query, "on", on, sizeof(on)) == ESP_OK) v = atoi(on);
phone_force_offhook(v != 0);
char resp[64];
snprintf(resp, sizeof(resp), "{\"ok\":true,\"forced_offhook\":%s}", v ? "true" : "false");
ESP_LOGI(TAG, "debug/offhook: forced %s", v ? "off-hook" : "on-hook");
return send_json(req, "200 OK", resp);
char val[16] = {0};
int ms = 4000;
if (httpd_query_key_value(query, "ms", val, sizeof(val)) == ESP_OK) {
int v = atoi(val);
if (v > 0 && v <= 8000) ms = v;
}
tones_stop();
audio_pa_set(true);
if (audio_capture_begin(ms, ms) != 0) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "capture init failed");
return ESP_FAIL;
}
httpd_resp_set_type(req, "audio/wav");
const uint32_t cap_rate = 16000; const uint16_t cap_ch = 1, cap_bits = 16;
const uint32_t byte_rate = cap_rate * cap_ch * (cap_bits / 8);
const uint16_t block_align = (uint16_t)(cap_ch * (cap_bits / 8));
const uint32_t big = 0xFFFFFFFFu; uint16_t fmt_pcm = 1; uint32_t fmt_size = 16;
uint8_t hdr[44];
memcpy(hdr, "RIFF", 4); memcpy(hdr + 4, &big, 4);
memcpy(hdr + 8, "WAVE", 4); memcpy(hdr + 12, "fmt ", 4);
memcpy(hdr + 16, &fmt_size, 4); memcpy(hdr + 20, &fmt_pcm, 2);
memcpy(hdr + 22, &cap_ch, 2); memcpy(hdr + 24, &cap_rate, 4);
memcpy(hdr + 28, &byte_rate, 4); memcpy(hdr + 32, &block_align, 2);
memcpy(hdr + 34, &cap_bits, 2); memcpy(hdr + 36, "data", 4); memcpy(hdr + 40, &big, 4);
httpd_resp_send_chunk(req, (const char *)hdr, sizeof(hdr));
const int max_frames = ms / 20;
int16_t mono[320];
float dc_x1 = 0.0f, dc_y1 = 0.0f; const float dc_R = 0.97f;
for (int f = 0; f < max_frames; f++) {
int64_t rms_sq = 0;
int n = audio_capture_read_frame(mono, 320, &rms_sq);
if (n < 0) break;
if (n == 0) continue;
for (int i = 0; i < n; i++) {
float xf = (float)mono[i];
float yf = xf - dc_x1 + dc_R * dc_y1;
dc_x1 = xf; dc_y1 = yf;
if (yf > 32767.0f) yf = 32767.0f; else if (yf < -32768.0f) yf = -32768.0f;
mono[i] = (int16_t)yf;
}
if (httpd_resp_send_chunk(req, (const char *)mono, (ssize_t)(n * sizeof(int16_t))) != ESP_OK) break;
}
audio_capture_end();
httpd_resp_send_chunk(req, NULL, 0);
return ESP_OK;
}
/* ── GET /debug/getfile?path=/x.wav (read a SPIFFS file back for diagnosis) ── */
@@ -684,7 +844,16 @@ esp_err_t net_init(void)
.sta = {
.threshold.authmode = (strlen(pwd) == 0)
? WIFI_AUTH_OPEN : WIFI_AUTH_WPA2_PSK,
.channel = CONFIG_PLIP_WIFI_CHANNEL,
/* Mesh-friendly association. A fixed .channel breaks on mesh APs
* (Freebox/Orange "Les cils") that roam channels via band-steering
* and DFS: if the node isn't on that channel at boot, association
* silently fails. So scan ALL channels (channel=0) and connect to
* the STRONGEST node broadcasting our SSID. The PLIP talks HTTP to
* the gateway, not ESP-NOW, so there is no co-channel constraint. */
.channel = 0,
.scan_method = WIFI_ALL_CHANNEL_SCAN,
.sort_method = WIFI_CONNECT_AP_BY_SIGNAL,
.failure_retry_cnt = 5,
},
};
strncpy((char *)wcfg.sta.ssid, ssid, sizeof(wcfg.sta.ssid) - 1);
@@ -711,7 +880,7 @@ esp_err_t net_init(void)
/* Start HTTP server. */
httpd_config_t hcfg = HTTPD_DEFAULT_CONFIG();
hcfg.server_port = 80;
hcfg.max_uri_handlers = 16;
hcfg.max_uri_handlers = 18;
hcfg.stack_size = 8192;
esp_err_t ret = httpd_start(&s_httpd, &hcfg);
@@ -732,6 +901,10 @@ esp_err_t net_init(void)
.uri = "/game/file", .method = HTTP_POST,
.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_cmd = {
.uri = "/game/cmd", .method = HTTP_POST,
.handler = handle_cmd_post, .user_ctx = NULL,
@@ -756,14 +929,14 @@ esp_err_t net_init(void)
.uri = "/debug/getfile", .method = HTTP_GET,
.handler = handle_debug_getfile, .user_ctx = NULL,
};
static const httpd_uri_t uri_debug_miccap = {
.uri = "/debug/miccap", .method = HTTP_GET,
.handler = handle_debug_miccap, .user_ctx = NULL,
};
static const httpd_uri_t uri_debug_dacvol = {
.uri = "/debug/dacvol", .method = HTTP_GET,
.handler = handle_debug_dacvol, .user_ctx = NULL,
};
static const httpd_uri_t uri_debug_offhook = {
.uri = "/debug/offhook", .method = HTTP_GET,
.handler = handle_debug_offhook, .user_ctx = NULL,
};
static const httpd_uri_t uri_debug_ring = {
.uri = "/debug/ring", .method = HTTP_GET,
.handler = handle_debug_ring, .user_ctx = NULL,
@@ -780,21 +953,27 @@ esp_err_t net_init(void)
.uri = "/debug/hookmon", .method = HTTP_GET,
.handler = handle_debug_hookmon, .user_ctx = NULL,
};
static const httpd_uri_t uri_debug_es8388 = {
.uri = "/debug/es8388", .method = HTTP_GET,
.handler = handle_debug_es8388, .user_ctx = NULL,
};
httpd_register_uri_handler(s_httpd, &uri_status);
httpd_register_uri_handler(s_httpd, &uri_scenario);
httpd_register_uri_handler(s_httpd, &uri_file);
httpd_register_uri_handler(s_httpd, &uri_file_delete);
httpd_register_uri_handler(s_httpd, &uri_cmd);
httpd_register_uri_handler(s_httpd, &uri_capture);
httpd_register_uri_handler(s_httpd, &uri_debug_regs);
httpd_register_uri_handler(s_httpd, &uri_debug_dial);
httpd_register_uri_handler(s_httpd, &uri_debug_vol);
httpd_register_uri_handler(s_httpd, &uri_debug_getfile);
httpd_register_uri_handler(s_httpd, &uri_debug_miccap);
httpd_register_uri_handler(s_httpd, &uri_debug_dacvol);
httpd_register_uri_handler(s_httpd, &uri_debug_offhook);
httpd_register_uri_handler(s_httpd, &uri_debug_ring);
httpd_register_uri_handler(s_httpd, &uri_debug_ringstop);
httpd_register_uri_handler(s_httpd, &uri_debug_slic);
httpd_register_uri_handler(s_httpd, &uri_debug_hookmon);
httpd_register_uri_handler(s_httpd, &uri_debug_es8388);
ESP_LOGI(TAG, "httpd up on :80 (GET /status, GET /debug/regs, GET /debug/dial, GET /debug/ring, GET /debug/ringstop, GET /debug/slic, POST /game/scenario, POST /game/file, POST /game/cmd, POST /voice/capture)");
return ESP_OK;
+20 -44
View File
@@ -34,20 +34,23 @@
#define TAG "phone"
#define DEBOUNCE_MS 30
#define HANGUP_THRESHOLD_MS 1500 /* open > this → real hangup, not a pulse.
* Raised from 500 ms: the A1S cradle contact
* is marginal and produces ~500 ms spurious
* opens that were resetting the call before
* the NPC greeting. A real hangup holds the
* line open indefinitely, so it still fires
* (~1.5 s later). Rotary pulses (~60 ms) and
* closed inter-digit gaps are unaffected. */
#define HANGUP_THRESHOLD_MS 2500 /* open > this → real hangup, not a pulse/flicker.
* HOOK DEBOUNCE: the A1S cradle contact is
* marginal and flickers open for up to ~2 s
* mid-call; treating those as a hangup dropped
* the live conversation. Requiring the line to
* stay open ≥ 2.5 s before hanging up rides
* through the flickers. A real hangup holds the
* line open indefinitely so it still fires
* (~2.5 s later). Rotary pulses (~60 ms) and a
* closed line are unaffected. */
#define TASK_STACK 4096
#define TASK_PRIO 5
#define RESYNC_STABLE_MS 600 /* if the raw hook level stably disagrees with
* s_offhook for this long (no pulse), the edge
* detection missed/flapped a transition →
* self-correct s_offhook to the physical level */
#define RESYNC_PICKUP_MS 600 /* off-hook (pickup) self-correct: fast, so an
* incoming call answers promptly. */
#define RESYNC_HANGUP_MS HANGUP_THRESHOLD_MS /* on-hook (hangup) self-correct:
* same long debounce as the prolonged-open path
* so a flickering contact never drops the call. */
/* Rotary pulse hardening (CONFIG_PLIP_DIAL_PULSE) */
#define PULSE_MIN_WIDTH_MS 20 /* ignore opens shorter than this (glitch filter) */
@@ -60,9 +63,6 @@
static volatile bool s_edge_pending = false;
static volatile bool s_ringing = false;
static volatile bool s_offhook = false; /* true = handset picked up */
static volatile bool s_hook_override = false; /* when true, ignore the physical hook
* and hold s_offhook at the forced value
* (debug: decouple from a flaky contact) */
/* IRAM_ATTR: ISR must live in IRAM on original ESP32. */
static void IRAM_ATTR on_hook_isr(void *arg)
@@ -163,15 +163,6 @@ static void phone_task(void *arg)
#endif
for (;;) {
/* Debug override: hold s_offhook at the forced value, ignore the
* physical hook entirely (decouples the voice loop from a flaky
* cradle contact). Set via phone_force_offhook() / GET /debug/offhook. */
if (s_hook_override) {
s_edge_pending = false;
resync_ms = 0;
vTaskDelay(pdMS_TO_TICKS(20));
continue;
}
if (s_edge_pending) {
s_edge_pending = false;
@@ -328,7 +319,11 @@ poll_sleep:
bool phys_off = (gpio_get_level(hook_gpio) == HOOK_OFFHOOK_LEVEL);
if (!pulse_active && phys_off != s_offhook) {
resync_ms += 10;
if (resync_ms >= RESYNC_STABLE_MS) {
/* Hook debounce: a PICKUP (→off-hook) is confirmed fast so calls
* answer promptly; a HANGUP (→on-hook) needs the long debounce so
* a flickering cradle contact can't drop a live call. */
int need = phys_off ? RESYNC_PICKUP_MS : RESYNC_HANGUP_MS;
if (resync_ms >= need) {
ESP_LOGW(TAG, "hook resync: physical=%s but s_offhook=%d — correcting",
phys_off ? "off-hook" : "on-hook", (int)s_offhook);
s_offhook = phys_off;
@@ -358,22 +353,3 @@ bool phone_is_offhook(void)
{
return s_offhook;
}
void phone_force_offhook(bool off)
{
/* Debug: override the physical hook. off=true → simulate pickup (DIALTONE),
* off=false → simulate hangup (IDLE). Stays in effect until reboot. */
s_hook_override = true;
if (off == s_offhook) {
ESP_LOGI(TAG, "force_offhook: already %s (override on)", off ? "off-hook" : "on-hook");
return;
}
s_offhook = off;
ESP_LOGI(TAG, "force_offhook: %s (override)", off ? "off-hook" : "on-hook");
audio_pa_set(off);
if (!off) {
audio_stop();
phone_ring_stop();
}
report_offhook(off);
}
-5
View File
@@ -31,11 +31,6 @@ void phone_ring_stop(void);
* Safe to call from any task; backed by a volatile flag updated in phone_task. */
bool phone_is_offhook(void);
/* Debug: force the hook state, overriding the physical contact until reboot.
* off=true simulates pickup (→ DIALTONE), off=false simulates hangup (→ IDLE).
* Lets the voice loop be validated independently of a flaky cradle contact. */
void phone_force_offhook(bool off);
#ifdef __cplusplus
}
#endif
+4
View File
@@ -52,3 +52,7 @@ CONFIG_PLIP_HOOK_ACTIVE_HIGH=y
# Example: CONFIG_PLIP_GATEWAY_URL="http://192.168.0.175:8401"
# CONFIG_PLIP_GATEWAY_TOKEN="testtoken"
# Default (Kconfig): http://192.168.0.50:8401 with empty token.
# Long file names on the SD (voice sample pack uses >8.3 names)
CONFIG_FATFS_LFN_HEAP=y
CONFIG_FATFS_MAX_LFN=255