Compare commits
34 Commits
b36b703f34
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 43dc1478b1 | |||
| b203f0e4de | |||
| 42b5d6375a | |||
| e37fa9ded2 | |||
| fd7af95bcf | |||
| 5519ed2f72 | |||
| a4efce4c20 | |||
| a8af29068b | |||
| c2527a0a66 | |||
| 0027970907 | |||
| a70963f64a | |||
| f58b090a34 | |||
| f3d03c637a | |||
| 39e0498221 | |||
| da7b6ace9c | |||
| be369fa260 | |||
| ace740a629 | |||
| 4ac3981845 | |||
| 653a299ea4 | |||
| 54022ed6cc | |||
| cfe429d885 | |||
| 82759ee536 | |||
| 8c076d81d6 | |||
| 99d79efcdd | |||
| 8dffe14972 | |||
| d71453d32e | |||
| ccc84da785 | |||
| 1561e613e1 | |||
| 4a6fc435a7 | |||
| ebcfb011d3 | |||
| 3c0eb75465 | |||
| aa7ae277ed | |||
| 37db47ad7b | |||
| f03156b65e |
@@ -1,5 +1,5 @@
|
||||
idf_component_register(
|
||||
SRCS "main.c" "voice_ws_client.c" "scenario_server.c" "plip_virtual.c" "plip_ui.c" "stimulus.c" "cmd_exec.c"
|
||||
SRCS "main.c" "voice_ws_client.c" "scenario_server.c" "plip_virtual.c" "plip_ui.c" "stimulus.c" "cmd_exec.c" "gamebook.c" "font_fr_14.c" "font_fr_24.c"
|
||||
INCLUDE_DIRS "."
|
||||
PRIV_REQUIRES
|
||||
driver
|
||||
@@ -13,5 +13,6 @@ idf_component_register(
|
||||
spiffs
|
||||
scenario_mesh
|
||||
espressif__esp-box-3
|
||||
espressif__esp_codec_dev
|
||||
lvgl__lvgl
|
||||
)
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
menu "Zacus BOX-3 Voice Configuration"
|
||||
|
||||
config BOX3_VOICE_STREAMING
|
||||
bool "Enable microphone + voice-bridge streaming"
|
||||
default n
|
||||
help
|
||||
Start the mic capture task and the WebSocket voice-bridge client.
|
||||
Off by default: as a touch gamebook the BOX-3 needs no microphone,
|
||||
and the continuous codec reads + bridge reconnect attempts interfere
|
||||
with speaker playback (the first narration clip came out silent) and
|
||||
flood the log. Enable only when using the BOX-3 as a voice device.
|
||||
|
||||
config ZACUS_WIFI_SSID
|
||||
string "WiFi SSID"
|
||||
default "zacus-net"
|
||||
|
||||
+100
-15
@@ -10,7 +10,7 @@
|
||||
//
|
||||
// Objective 2 — play: real WAV streaming to I2S
|
||||
// 1. Mount SD card (best-effort, /sdcard). Parse WAV header (PCM-16).
|
||||
// 2. Stream PCM samples via s_spk_handle (extern, same I2S channel as TTS).
|
||||
// 2. Stream PCM samples via the ES8311 codec (esp_codec_dev), same path as TTS.
|
||||
// 3. If SD absent / file not found → fallback bip + log.
|
||||
// 4. Embedded test cue always available at virtual path "embedded://" or
|
||||
// when the requested path starts with "embedded:" — proves WAV decode + I2S.
|
||||
@@ -33,16 +33,16 @@
|
||||
|
||||
#include "embedded_wav.h" // s_embedded_wav[], EMBEDDED_WAV_SIZE
|
||||
|
||||
// audio_play_tone and s_spk_handle are defined in main.c
|
||||
// audio_play_tone is defined in main.c (writes through the ES8311 codec).
|
||||
extern void audio_play_tone(float frequency, int duration_ms);
|
||||
|
||||
// Speaker I2S handle — obtained from main.c via a weak accessor.
|
||||
// We declare a weak symbol here; main.c must define cmd_exec_set_spk_handle()
|
||||
// and call it after speaker_init(). Alternatively we use the driver directly.
|
||||
#include "driver/i2s_std.h"
|
||||
static i2s_chan_handle_t s_spk_handle_local = NULL;
|
||||
// Speaker codec (ES8311) handle — obtained from main.c after speaker_init().
|
||||
// Audio is streamed through esp_codec_dev so the ES8311 DAC is actually driven
|
||||
// (a raw I2S write leaves the codec muted).
|
||||
#include "esp_codec_dev.h"
|
||||
static esp_codec_dev_handle_t s_spk_handle_local = NULL;
|
||||
|
||||
void cmd_exec_set_spk_handle(i2s_chan_handle_t h)
|
||||
void cmd_exec_set_spk_handle(esp_codec_dev_handle_t h)
|
||||
{
|
||||
s_spk_handle_local = h;
|
||||
}
|
||||
@@ -344,13 +344,11 @@ static esp_err_t stream_pcm16(const uint8_t *pcm, size_t byte_len,
|
||||
size_t offset = 0;
|
||||
while (offset < byte_len) {
|
||||
size_t chunk = (byte_len - offset < 1024) ? (byte_len - offset) : 1024;
|
||||
size_t written = 0;
|
||||
esp_err_t ret = i2s_channel_write(s_spk_handle_local,
|
||||
pcm + offset, chunk,
|
||||
&written, pdMS_TO_TICKS(500));
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "I2S write error: %s", esp_err_to_name(ret));
|
||||
return ret;
|
||||
int ret = esp_codec_dev_write(s_spk_handle_local,
|
||||
(void *)(pcm + offset), (int)chunk);
|
||||
if (ret != ESP_CODEC_DEV_OK) {
|
||||
ESP_LOGW(TAG, "codec write error: %d", ret);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
offset += chunk;
|
||||
}
|
||||
@@ -488,6 +486,93 @@ static void play_task(void *arg)
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
// ─── Gamebook narration player (async, streamed, interruptible) ──────────────
|
||||
// One persistent task plays one WAV at a time, streaming straight from the SD
|
||||
// in small chunks (no full-file malloc → any length, RAM-safe). A new request
|
||||
// or a stop flag breaks the stream so tapping a choice cuts the narration.
|
||||
|
||||
static TaskHandle_t s_gb_task = NULL;
|
||||
static char s_gb_path[160];
|
||||
static volatile bool s_gb_new = false;
|
||||
static volatile bool s_gb_stop = false;
|
||||
|
||||
static void gb_play_task(void *arg)
|
||||
{
|
||||
(void) arg;
|
||||
/* The task is a singleton (guarded by s_gb_task), so these large buffers
|
||||
* are kept static — on the stack they ate ~2.1 KB and, together with the
|
||||
* FATFS fread internals + i2s + logging, overflowed the 4 KB stack and
|
||||
* rebooted the board when a story started. */
|
||||
static uint8_t hdr[64];
|
||||
static uint8_t buf[2048];
|
||||
for (;;) {
|
||||
if (!s_gb_new) { vTaskDelay(pdMS_TO_TICKS(20)); continue; }
|
||||
char path[160];
|
||||
strncpy(path, s_gb_path, sizeof(path) - 1);
|
||||
path[sizeof(path) - 1] = '\0';
|
||||
s_gb_new = false;
|
||||
s_gb_stop = false;
|
||||
|
||||
/* The SD is normally already mounted (the gamebook mounts it at init).
|
||||
* Try the file directly first — re-mounting an already-mounted card via
|
||||
* bsp_sdcard_mount() FAILS and tears down the existing VFS, which made
|
||||
* the very first narration clip silent. Only mount if the open fails. */
|
||||
FILE *f = fopen(path, "rb");
|
||||
if (!f) {
|
||||
ensure_sd_mounted();
|
||||
f = fopen(path, "rb");
|
||||
}
|
||||
if (!f) { ESP_LOGW(TAG, "gb: open %s failed", path); continue; }
|
||||
size_t hn = fread(hdr, 1, sizeof(hdr), f);
|
||||
wav_info_t wi;
|
||||
if (parse_wav_header(hdr, hn, &wi) != ESP_OK || wi.bits_per_sample != 16) {
|
||||
ESP_LOGW(TAG, "gb: bad/usupported WAV %s", path);
|
||||
fclose(f);
|
||||
continue;
|
||||
}
|
||||
fseek(f, wi.data_offset, SEEK_SET);
|
||||
/* Wake the codec/PA before the real audio: after idle the ES8311 mutes
|
||||
* and ramps up on the first write, swallowing the start of the clip.
|
||||
* A short silent lead-in lets the unmute settle so the narration plays
|
||||
* from the very first word. */
|
||||
memset(buf, 0, sizeof(buf));
|
||||
for (int i = 0; i < 4 && s_spk_handle_local && !s_gb_stop && !s_gb_new; i++) {
|
||||
esp_codec_dev_write(s_spk_handle_local, buf, (int)sizeof(buf));
|
||||
}
|
||||
size_t remaining = wi.data_size;
|
||||
ESP_LOGI(TAG, "gb: play %s (%u B, %u Hz)", path,
|
||||
(unsigned)wi.data_size, (unsigned)wi.sample_rate);
|
||||
while (remaining > 0 && !s_gb_stop && !s_gb_new) {
|
||||
size_t want = remaining < sizeof(buf) ? remaining : sizeof(buf);
|
||||
size_t got = fread(buf, 1, want, f);
|
||||
if (got == 0) break;
|
||||
if (s_spk_handle_local) {
|
||||
esp_codec_dev_write(s_spk_handle_local, buf, (int)got);
|
||||
}
|
||||
remaining -= got;
|
||||
}
|
||||
fclose(f);
|
||||
}
|
||||
}
|
||||
|
||||
void cmd_exec_play_file_async(const char *path)
|
||||
{
|
||||
if (!path || !path[0]) return;
|
||||
strncpy(s_gb_path, path, sizeof(s_gb_path) - 1);
|
||||
s_gb_path[sizeof(s_gb_path) - 1] = '\0';
|
||||
s_gb_stop = true; // interrupt whatever is playing
|
||||
s_gb_new = true; // … and pick up the new clip
|
||||
if (!s_gb_task) {
|
||||
xTaskCreate(gb_play_task, "gb_play", 6144, NULL, 4, &s_gb_task);
|
||||
}
|
||||
}
|
||||
|
||||
void cmd_exec_stop_play(void)
|
||||
{
|
||||
s_gb_stop = true;
|
||||
s_gb_new = false;
|
||||
}
|
||||
|
||||
// ─── op=play ──────────────────────────────────────────────────────────────────
|
||||
|
||||
static esp_err_t exec_play(const cJSON *a)
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
#include <stddef.h>
|
||||
#include "esp_err.h"
|
||||
#include "driver/i2s_std.h"
|
||||
#include "esp_codec_dev.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
@@ -30,9 +30,17 @@ extern "C" {
|
||||
// ESP_ERR_NOT_SUPPORTED for unknown ops (all logged, no crash).
|
||||
esp_err_t cmd_exec_handle(const char *data, size_t len);
|
||||
|
||||
// Provide the speaker I2S channel handle so exec_play() can stream audio.
|
||||
// Provide the speaker codec (ES8311) handle so exec_play() can stream audio.
|
||||
// Must be called from main.c after speaker_init(), before any CMD arrives.
|
||||
void cmd_exec_set_spk_handle(i2s_chan_handle_t h);
|
||||
void cmd_exec_set_spk_handle(esp_codec_dev_handle_t h);
|
||||
|
||||
// Play a 16 kHz mono 16-bit WAV from the SD asynchronously, streamed in chunks
|
||||
// (RAM-safe for long narration) and INTERRUPTIBLE: a new call stops the current
|
||||
// clip and starts the new one. Used by the touch gamebook for passage audio.
|
||||
void cmd_exec_play_file_async(const char *path);
|
||||
|
||||
// Stop the async narration (no-op if nothing is playing).
|
||||
void cmd_exec_stop_play(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,238 @@
|
||||
// gamebook.c — see gamebook.h. Touch CYOA for the ESP32-S3-BOX-3.
|
||||
#include "gamebook.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_heap_caps.h"
|
||||
|
||||
#include "bsp/esp-bsp.h"
|
||||
#include "lvgl.h"
|
||||
#include "cmd_exec.h" // async WAV narration player
|
||||
|
||||
static const char *TAG = "gamebook";
|
||||
|
||||
// Custom fonts with French glyphs (ASCII + Latin-1 + ’ + …). See main/font_fr_*.c.
|
||||
LV_FONT_DECLARE(font_fr_14);
|
||||
LV_FONT_DECLARE(font_fr_24);
|
||||
|
||||
#define GB_DIR "/sdcard/gamebook"
|
||||
#define GB_LIBRARY GB_DIR "/library.json"
|
||||
#define GB_JSON_MAX (256 * 1024) // expanded books are ~80 KB of JSON
|
||||
|
||||
// Colours (Workbench-ish palette).
|
||||
#define COL_BG 0x101820
|
||||
#define COL_TITLE 0xFFCC55
|
||||
#define COL_TEXT 0xF0F0F0
|
||||
#define COL_BTN 0x224466
|
||||
|
||||
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 lv_obj_t *s_root = NULL; // scrollable full-screen column
|
||||
|
||||
// ── PSRAM allocators for cJSON (book trees are large) ───────────────────────
|
||||
static void *gb_malloc(size_t sz) { return heap_caps_malloc(sz, MALLOC_CAP_SPIRAM); }
|
||||
static void gb_free(void *p) { heap_caps_free(p); }
|
||||
|
||||
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); ESP_LOGW(TAG, "%s size %ld", path, sz); return NULL; }
|
||||
char *buf = heap_caps_malloc((size_t)sz + 1, MALLOC_CAP_SPIRAM);
|
||||
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);
|
||||
heap_caps_free(buf);
|
||||
if (!root) ESP_LOGW(TAG, "malformed JSON: %s", path);
|
||||
return root;
|
||||
}
|
||||
|
||||
// ── Root container (created once, cleared per view) ─────────────────────────
|
||||
// All UI functions below assume the LVGL lock is held (gamebook_init takes it;
|
||||
// button callbacks already run inside the LVGL task / lock).
|
||||
static void ensure_root(void)
|
||||
{
|
||||
if (s_root) { lv_obj_clean(s_root); return; }
|
||||
lv_obj_t *scr = lv_screen_active();
|
||||
s_root = lv_obj_create(scr);
|
||||
lv_obj_set_size(s_root, LV_PCT(100), LV_PCT(100));
|
||||
lv_obj_set_pos(s_root, 0, 0);
|
||||
lv_obj_set_style_bg_color(s_root, lv_color_hex(COL_BG), 0);
|
||||
lv_obj_set_style_bg_opa(s_root, LV_OPA_COVER, 0);
|
||||
lv_obj_set_style_border_width(s_root, 0, 0);
|
||||
lv_obj_set_style_radius(s_root, 0, 0);
|
||||
lv_obj_set_style_pad_all(s_root, 8, 0);
|
||||
lv_obj_set_style_pad_row(s_root, 8, 0);
|
||||
lv_obj_set_flex_flow(s_root, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_scroll_dir(s_root, LV_DIR_VER);
|
||||
}
|
||||
|
||||
static lv_obj_t *add_title(const char *txt)
|
||||
{
|
||||
lv_obj_t *l = lv_label_create(s_root);
|
||||
lv_obj_set_style_text_font(l, &font_fr_24, 0);
|
||||
lv_obj_set_style_text_color(l, lv_color_hex(COL_TITLE), 0);
|
||||
lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_set_width(l, LV_PCT(100));
|
||||
lv_label_set_text(l, txt);
|
||||
return l;
|
||||
}
|
||||
|
||||
static lv_obj_t *add_text(const char *txt)
|
||||
{
|
||||
lv_obj_t *l = lv_label_create(s_root);
|
||||
lv_obj_set_style_text_font(l, &font_fr_14, 0);
|
||||
lv_obj_set_style_text_color(l, lv_color_hex(COL_TEXT), 0);
|
||||
lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_set_width(l, LV_PCT(100));
|
||||
lv_label_set_text(l, txt);
|
||||
return l;
|
||||
}
|
||||
|
||||
static lv_obj_t *add_button(const char *txt, lv_event_cb_t cb, void *user)
|
||||
{
|
||||
lv_obj_t *b = lv_button_create(s_root);
|
||||
lv_obj_set_width(b, LV_PCT(100));
|
||||
lv_obj_set_style_bg_color(b, lv_color_hex(COL_BTN), 0);
|
||||
lv_obj_set_style_pad_all(b, 10, 0);
|
||||
lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, user);
|
||||
lv_obj_t *l = lv_label_create(b);
|
||||
lv_obj_set_style_text_font(l, &font_fr_14, 0);
|
||||
lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_set_width(l, LV_PCT(100));
|
||||
lv_label_set_text(l, txt);
|
||||
return b;
|
||||
}
|
||||
|
||||
// Forward decls
|
||||
static void show_library(void);
|
||||
static void enter_passage(const char *pid);
|
||||
|
||||
static void tile_cb(lv_event_t *e);
|
||||
static void choice_cb(lv_event_t *e);
|
||||
static void menu_cb(lv_event_t *e);
|
||||
|
||||
// ── Library menu ────────────────────────────────────────────────────────────
|
||||
static void show_library(void)
|
||||
{
|
||||
cmd_exec_stop_play(); // silence any passage narration
|
||||
if (s_book) { cJSON_Delete(s_book); s_book = NULL; s_passages = NULL; }
|
||||
ensure_root();
|
||||
add_title("Choisis ton histoire");
|
||||
for (int i = 0; i < s_lib_n; i++) {
|
||||
const cJSON *t = cJSON_GetObjectItem(cJSON_GetArrayItem(s_lib, i), "title");
|
||||
add_button(cJSON_IsString(t) ? t->valuestring : "?",
|
||||
tile_cb, (void *)(intptr_t)i);
|
||||
}
|
||||
lv_obj_scroll_to_y(s_root, 0, LV_ANIM_OFF);
|
||||
}
|
||||
|
||||
// ── Passage ─────────────────────────────────────────────────────────────────
|
||||
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; }
|
||||
const cJSON *screen = cJSON_GetObjectItem(p, "screen");
|
||||
const cJSON *text = cJSON_GetObjectItem(p, "text");
|
||||
const cJSON *choices = cJSON_GetObjectItem(p, "choices");
|
||||
|
||||
ensure_root();
|
||||
add_title(cJSON_IsString(screen) ? screen->valuestring : "");
|
||||
add_text(cJSON_IsString(text) ? text->valuestring : "");
|
||||
|
||||
// Narration: play this passage's WAV from the SD if the pack has audio.
|
||||
// Interruptible — tapping a choice cuts it and plays the next passage.
|
||||
const cJSON *wav = cJSON_GetObjectItem(p, "wav");
|
||||
if (cJSON_IsString(wav) && wav->valuestring[0]) {
|
||||
char path[160];
|
||||
snprintf(path, sizeof(path), "%s/%s", GB_DIR, wav->valuestring);
|
||||
cmd_exec_play_file_async(path);
|
||||
}
|
||||
int n = cJSON_IsArray(choices) ? cJSON_GetArraySize(choices) : 0;
|
||||
if (n == 0) {
|
||||
add_button("Revenir au menu", menu_cb, NULL);
|
||||
} else {
|
||||
for (int i = 0; i < n; i++) {
|
||||
const cJSON *c = cJSON_GetArrayItem(choices, i);
|
||||
const cJSON *lbl = cJSON_GetObjectItem(c, "label");
|
||||
const cJSON *g = cJSON_GetObjectItem(c, "goto");
|
||||
// goto valuestring stays valid while s_book is alive → use as user_data
|
||||
add_button(cJSON_IsString(lbl) ? lbl->valuestring : "?",
|
||||
choice_cb, cJSON_IsString(g) ? g->valuestring : NULL);
|
||||
}
|
||||
}
|
||||
lv_obj_scroll_to_y(s_root, 0, LV_ANIM_OFF);
|
||||
ESP_LOGI(TAG, "passage '%s' (%d choices)", pid, n);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
if (s_book) cJSON_Delete(s_book);
|
||||
s_book = book;
|
||||
s_passages = (cJSON *)passages;
|
||||
ESP_LOGI(TAG, "load book #%d @ '%s'", idx, start->valuestring);
|
||||
enter_passage(start->valuestring);
|
||||
}
|
||||
|
||||
// ── Touch callbacks (run inside the LVGL task → lock already held) ───────────
|
||||
static void tile_cb(lv_event_t *e) { load_book((int)(intptr_t)lv_event_get_user_data(e)); }
|
||||
static void menu_cb(lv_event_t *e) { (void)e; show_library(); }
|
||||
static void choice_cb(lv_event_t *e)
|
||||
{
|
||||
const char *goto_id = (const char *)lv_event_get_user_data(e);
|
||||
if (goto_id) enter_passage(goto_id);
|
||||
}
|
||||
|
||||
// ── Public init ─────────────────────────────────────────────────────────────
|
||||
void gamebook_init(void)
|
||||
{
|
||||
cJSON_Hooks hooks = { .malloc_fn = gb_malloc, .free_fn = gb_free };
|
||||
cJSON_InitHooks(&hooks); // keep the big book trees in PSRAM
|
||||
|
||||
if (bsp_sdcard_mount() != ESP_OK) {
|
||||
ESP_LOGW(TAG, "no SD card — gamebook disabled");
|
||||
return;
|
||||
}
|
||||
s_lib_root = load_json(GB_LIBRARY);
|
||||
if (!s_lib_root) { ESP_LOGW(TAG, "no library.json — gamebook disabled"); 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;
|
||||
ESP_LOGW(TAG, "empty library");
|
||||
return;
|
||||
}
|
||||
s_lib_n = cJSON_GetArraySize(s_lib);
|
||||
|
||||
bsp_display_brightness_set(80);
|
||||
if (bsp_display_lock(1000)) {
|
||||
show_library();
|
||||
bsp_display_unlock();
|
||||
}
|
||||
ESP_LOGI(TAG, "touch gamebook up (%d stories)", s_lib_n);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
// gamebook — "livre dont tu es le héros" TACTILE pour l'ESP32-S3-BOX-3.
|
||||
//
|
||||
// Écran 320x240 + tactile : on lit le passage à l'écran et on TOUCHE un bouton
|
||||
// pour choisir. Réutilise le pack gamebook du master sur la SD :
|
||||
// /sdcard/gamebook/{library.json, <id>.json}
|
||||
// où chaque passage porte {screen, text, choices:[{label, goto}]} (le champ
|
||||
// "wav" éventuel est ignoré — version texte/tactile, pas d'audio requis).
|
||||
//
|
||||
// S'appuie sur la stack LVGL déjà démarrée par bsp_display_start() : on
|
||||
// construit une UUI (menu d'histoires en liste, puis pages tactiles) et la
|
||||
// navigation se fait dans les callbacks de boutons LVGL. 100% local, hors-ligne.
|
||||
|
||||
#include <stdbool.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
// Monte la SD, charge la bibliothèque et affiche le menu tactile des histoires.
|
||||
// À appeler une fois depuis app_main, après bsp_display_start() et le serveur
|
||||
// de fichiers (pour que la SD soit accessible). No-op si aucun pack présent.
|
||||
void gamebook_init(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
+94
-72
@@ -28,6 +28,7 @@
|
||||
#include "scenario_server.h"
|
||||
#include "plip_virtual.h"
|
||||
#include "plip_ui.h"
|
||||
#include "gamebook.h"
|
||||
#include "stimulus.h"
|
||||
#include "scenario_mesh.h"
|
||||
#include "cmd_exec.h"
|
||||
@@ -39,8 +40,12 @@ static const char *TAG = "zacus-voice";
|
||||
|
||||
/* --------------- Shared state --------------- */
|
||||
|
||||
static i2s_chan_handle_t s_spk_handle = NULL; /* Speaker TX channel */
|
||||
static i2s_chan_handle_t s_mic_handle = NULL; /* Microphone RX channel */
|
||||
/* Audio now goes through the ES8311 (speaker) / ES7210 (mic) codecs via the
|
||||
* BSP + esp_codec_dev. bsp_audio_init() owns the single shared I2S port; we
|
||||
* must NOT create our own raw I2S channels (that left the ES8311 DAC unpowered
|
||||
* and the speaker silent). */
|
||||
static esp_codec_dev_handle_t s_spk_codec = NULL; /* ES8311 output (DAC) */
|
||||
static esp_codec_dev_handle_t s_mic_codec = NULL; /* ES7210 input (ADC) */
|
||||
static volatile bool s_wifi_connected = false;
|
||||
static volatile bool s_voice_streaming = false;
|
||||
|
||||
@@ -113,11 +118,10 @@ static esp_err_t wifi_init_sta(void)
|
||||
// generator can sequence a melody for the master's microphone.
|
||||
void audio_play_tone(float frequency, int duration_ms)
|
||||
{
|
||||
if (!s_spk_handle || duration_ms <= 0) return;
|
||||
if (!s_spk_codec || duration_ms <= 0) return;
|
||||
const int total_samples = AUDIO_SAMPLE_RATE * duration_ms / 1000;
|
||||
const float amplitude = 16000.0f;
|
||||
int16_t buffer[256];
|
||||
size_t bytes_written = 0;
|
||||
int sample_idx = 0;
|
||||
while (sample_idx < total_samples) {
|
||||
int chunk = (total_samples - sample_idx < 256)
|
||||
@@ -126,29 +130,27 @@ void audio_play_tone(float frequency, int duration_ms)
|
||||
float t = (float)(sample_idx + i) / (float)AUDIO_SAMPLE_RATE;
|
||||
buffer[i] = (int16_t)(amplitude * sinf(2.0f * M_PI * frequency * t));
|
||||
}
|
||||
i2s_channel_write(s_spk_handle, buffer, chunk * sizeof(int16_t),
|
||||
&bytes_written, portMAX_DELAY);
|
||||
esp_codec_dev_write(s_spk_codec, buffer, chunk * sizeof(int16_t));
|
||||
sample_idx += chunk;
|
||||
}
|
||||
}
|
||||
|
||||
static void audio_test_tone(void)
|
||||
{
|
||||
if (!s_spk_handle) {
|
||||
if (!s_spk_codec) {
|
||||
ESP_LOGW(TAG, "Speaker not initialized, skipping test tone");
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Playing 440 Hz test tone (1 second)...");
|
||||
|
||||
/* Generate 1 second of 440 Hz sine wave using persistent speaker handle */
|
||||
/* Generate 1 second of 440 Hz sine wave through the ES8311 codec */
|
||||
const int duration_ms = 1000;
|
||||
const int total_samples = AUDIO_SAMPLE_RATE * duration_ms / 1000;
|
||||
const float frequency = 440.0f;
|
||||
const float amplitude = 16000.0f;
|
||||
|
||||
int16_t buffer[256];
|
||||
size_t bytes_written = 0;
|
||||
int sample_idx = 0;
|
||||
|
||||
while (sample_idx < total_samples) {
|
||||
@@ -157,7 +159,7 @@ static void audio_test_tone(void)
|
||||
float t = (float)(sample_idx + i) / (float)AUDIO_SAMPLE_RATE;
|
||||
buffer[i] = (int16_t)(amplitude * sinf(2.0f * M_PI * frequency * t));
|
||||
}
|
||||
i2s_channel_write(s_spk_handle, buffer, chunk * sizeof(int16_t), &bytes_written, portMAX_DELAY);
|
||||
esp_codec_dev_write(s_spk_codec, buffer, chunk * sizeof(int16_t));
|
||||
sample_idx += chunk;
|
||||
}
|
||||
|
||||
@@ -173,15 +175,13 @@ static void tts_audio_callback(const uint8_t *data, size_t len)
|
||||
* Called from WebSocket event context with incoming TTS PCM16 data.
|
||||
* Write directly to the speaker I2S channel.
|
||||
*/
|
||||
if (!s_spk_handle || len == 0) {
|
||||
if (!s_spk_codec || len == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
size_t bytes_written = 0;
|
||||
esp_err_t ret = i2s_channel_write(s_spk_handle, data, len,
|
||||
&bytes_written, pdMS_TO_TICKS(200));
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "TTS write to speaker failed: %s", esp_err_to_name(ret));
|
||||
int ret = esp_codec_dev_write(s_spk_codec, (void *)data, (int)len);
|
||||
if (ret != ESP_CODEC_DEV_OK) {
|
||||
ESP_LOGW(TAG, "TTS write to speaker failed: %d", ret);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,34 +189,42 @@ static void tts_audio_callback(const uint8_t *data, size_t len)
|
||||
|
||||
static esp_err_t speaker_init(void)
|
||||
{
|
||||
/* Enable power amplifier */
|
||||
/* Init the ES8311 output codec via the BSP. This also runs bsp_audio_init()
|
||||
* (shared duplex I2S port) + bsp_i2c_init() and configures the power
|
||||
* amplifier (BSP_POWER_AMP_IO = GPIO46). The ES8311 DAC is configured over
|
||||
* I2C here — without this the speaker stays muted no matter what we push on
|
||||
* I2S. */
|
||||
s_spk_codec = bsp_audio_codec_speaker_init();
|
||||
if (!s_spk_codec) {
|
||||
ESP_LOGE(TAG, "bsp_audio_codec_speaker_init failed — speaker unavailable");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
/* All speaker audio in this app is 16 kHz / 16-bit / mono PCM. */
|
||||
esp_codec_dev_sample_info_t fs = {
|
||||
.bits_per_sample = AUDIO_BITS,
|
||||
.channel = AUDIO_CHANNELS,
|
||||
.channel_mask = 0,
|
||||
.sample_rate = AUDIO_SAMPLE_RATE,
|
||||
.mclk_multiple = 0,
|
||||
};
|
||||
int ret = esp_codec_dev_open(s_spk_codec, &fs);
|
||||
if (ret != ESP_CODEC_DEV_OK) {
|
||||
ESP_LOGE(TAG, "esp_codec_dev_open (speaker) failed: %d", ret);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
/* Output volume 0..100. */
|
||||
esp_codec_dev_set_out_vol(s_spk_codec, 100);
|
||||
|
||||
/* Belt-and-suspenders: the es8311 driver is configured with pa_pin=GPIO46
|
||||
* (pa_reverted=false) and should raise it on open, but the speaker stays
|
||||
* silent in practice — force the power amplifier on explicitly. */
|
||||
gpio_set_direction(BOX3_PA_ENABLE, GPIO_MODE_OUTPUT);
|
||||
gpio_set_level(BOX3_PA_ENABLE, 1);
|
||||
|
||||
i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER);
|
||||
ESP_ERROR_CHECK(i2s_new_channel(&chan_cfg, &s_spk_handle, NULL));
|
||||
|
||||
i2s_std_config_t std_cfg = {
|
||||
.clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(AUDIO_SAMPLE_RATE),
|
||||
.slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO),
|
||||
.gpio_cfg = {
|
||||
.mclk = BOX3_I2S_MCLK,
|
||||
.bclk = BOX3_I2S_BCLK,
|
||||
.ws = BOX3_I2S_WS,
|
||||
.dout = BOX3_I2S_DOUT,
|
||||
.din = I2S_GPIO_UNUSED,
|
||||
.invert_flags = {
|
||||
.mclk_inv = false,
|
||||
.bclk_inv = false,
|
||||
.ws_inv = false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
ESP_ERROR_CHECK(i2s_channel_init_std_mode(s_spk_handle, &std_cfg));
|
||||
ESP_ERROR_CHECK(i2s_channel_enable(s_spk_handle));
|
||||
|
||||
ESP_LOGI(TAG, "Speaker I2S initialized (I2S_NUM_0)");
|
||||
ESP_LOGI(TAG, "Speaker codec (ES8311) initialized @ %d Hz, vol=100, PA on",
|
||||
AUDIO_SAMPLE_RATE);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
@@ -226,43 +234,45 @@ static void mic_monitor_task(void *arg)
|
||||
{
|
||||
ESP_LOGI(TAG, "Starting mic capture task...");
|
||||
|
||||
i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_1, I2S_ROLE_MASTER);
|
||||
ESP_ERROR_CHECK(i2s_new_channel(&chan_cfg, NULL, &s_mic_handle));
|
||||
/* Init the ES7210 input codec via the BSP. It shares the same I2S port
|
||||
* (already created by speaker_init -> bsp_audio_init); creating a separate
|
||||
* raw I2S channel here would conflict with that shared port. */
|
||||
s_mic_codec = bsp_audio_codec_microphone_init();
|
||||
if (!s_mic_codec) {
|
||||
ESP_LOGE(TAG, "bsp_audio_codec_microphone_init failed — mic unavailable");
|
||||
vTaskDelete(NULL);
|
||||
return;
|
||||
}
|
||||
|
||||
i2s_std_config_t std_cfg = {
|
||||
.clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(AUDIO_SAMPLE_RATE),
|
||||
.slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO),
|
||||
.gpio_cfg = {
|
||||
.mclk = I2S_GPIO_UNUSED,
|
||||
.bclk = BOX3_I2S_BCLK,
|
||||
.ws = BOX3_I2S_WS,
|
||||
.dout = I2S_GPIO_UNUSED,
|
||||
.din = BOX3_I2S_DIN,
|
||||
.invert_flags = {
|
||||
.mclk_inv = false,
|
||||
.bclk_inv = false,
|
||||
.ws_inv = false,
|
||||
},
|
||||
},
|
||||
esp_codec_dev_sample_info_t fs = {
|
||||
.bits_per_sample = AUDIO_BITS,
|
||||
.channel = AUDIO_CHANNELS,
|
||||
.channel_mask = 0,
|
||||
.sample_rate = AUDIO_SAMPLE_RATE,
|
||||
.mclk_multiple = 0,
|
||||
};
|
||||
|
||||
ESP_ERROR_CHECK(i2s_channel_init_std_mode(s_mic_handle, &std_cfg));
|
||||
ESP_ERROR_CHECK(i2s_channel_enable(s_mic_handle));
|
||||
int oret = esp_codec_dev_open(s_mic_codec, &fs);
|
||||
if (oret != ESP_CODEC_DEV_OK) {
|
||||
ESP_LOGE(TAG, "esp_codec_dev_open (mic) failed: %d", oret);
|
||||
vTaskDelete(NULL);
|
||||
return;
|
||||
}
|
||||
/* ES7210 input gain (dB). 30 dB is a sane default for the BOX-3 mic array. */
|
||||
esp_codec_dev_set_in_gain(s_mic_codec, 30.0f);
|
||||
|
||||
int16_t buffer[AUDIO_FRAME_SAMPLES];
|
||||
size_t bytes_read = 0;
|
||||
int rms_log_counter = 0;
|
||||
|
||||
while (1) {
|
||||
esp_err_t ret = i2s_channel_read(s_mic_handle, buffer,
|
||||
AUDIO_FRAME_SAMPLES * sizeof(int16_t),
|
||||
&bytes_read, pdMS_TO_TICKS(1000));
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Mic read error: %s", esp_err_to_name(ret));
|
||||
int ret = esp_codec_dev_read(s_mic_codec, buffer,
|
||||
AUDIO_FRAME_SAMPLES * sizeof(int16_t));
|
||||
if (ret != ESP_CODEC_DEV_OK) {
|
||||
ESP_LOGW(TAG, "Mic read error: %d", ret);
|
||||
vTaskDelay(pdMS_TO_TICKS(20));
|
||||
continue;
|
||||
}
|
||||
|
||||
int samples = bytes_read / sizeof(int16_t);
|
||||
int samples = AUDIO_FRAME_SAMPLES;
|
||||
|
||||
/* If voice streaming is active, send PCM to bridge */
|
||||
if (s_voice_streaming) {
|
||||
@@ -422,14 +432,19 @@ void app_main(void)
|
||||
/* Initialize persistent speaker output (used for test tone + TTS playback) */
|
||||
speaker_init();
|
||||
|
||||
/* Pass speaker handle to CMD executor for real WAV playback */
|
||||
cmd_exec_set_spk_handle(s_spk_handle);
|
||||
/* Pass speaker codec handle to CMD executor for real WAV playback */
|
||||
cmd_exec_set_spk_handle(s_spk_codec);
|
||||
|
||||
/* Play test tone to verify audio output */
|
||||
audio_test_tone();
|
||||
|
||||
/* Start mic capture task (reads I2S mic, streams to bridge when active) */
|
||||
#if CONFIG_BOX3_VOICE_STREAMING
|
||||
/* Start mic capture task (reads I2S mic, streams to bridge when active).
|
||||
* Off by default: as a touch gamebook the BOX-3 needs no mic, and the
|
||||
* continuous codec reads + bridge reconnects interfered with the speaker
|
||||
* (the first narration clip came out silent) and spammed the log. */
|
||||
xTaskCreatePinnedToCore(mic_monitor_task, "mic_capture", 4096, NULL, 5, NULL, 1);
|
||||
#endif
|
||||
|
||||
/* Start button handler (BOOT button toggles voice streaming) */
|
||||
xTaskCreate(button_task, "button", 2048, NULL, 4, NULL);
|
||||
@@ -438,8 +453,10 @@ void app_main(void)
|
||||
ESP_LOGI(TAG, "Connecting to WiFi...");
|
||||
wifi_init_sta();
|
||||
|
||||
#if CONFIG_BOX3_VOICE_STREAMING
|
||||
/* Start voice bridge connection task (waits for WiFi, then connects WS) */
|
||||
xTaskCreate(voice_bridge_task, "voice_bridge", 6144, NULL, 5, NULL);
|
||||
#endif
|
||||
|
||||
/* Start the scenario hot-load HTTP server (POST /game/scenario).
|
||||
* httpd_start binds to all netifs — works as soon as the WiFi STA has an IP. */
|
||||
@@ -449,7 +466,7 @@ void app_main(void)
|
||||
/* Phone-less PLIP annex: same REST contract as PLIP_FIRMWARE
|
||||
* (POST /ring /stop /play, GET /status), ring on the speaker,
|
||||
* BOOT button as the virtual hook switch. */
|
||||
if (plip_virtual_init(scenario_server_handle(), s_spk_handle) != ESP_OK) {
|
||||
if (plip_virtual_init(scenario_server_handle(), s_spk_codec) != ESP_OK) {
|
||||
ESP_LOGW(TAG, "plip_virtual_init failed — virtual phone unavailable");
|
||||
} else if (plip_ui_init() != ESP_OK) {
|
||||
/* REST/ESP-NOW phone still works headless if the UI fails. */
|
||||
@@ -463,6 +480,11 @@ void app_main(void)
|
||||
} else {
|
||||
ESP_LOGW(TAG, "stimulus_init failed — QR/melody generator off");
|
||||
}
|
||||
|
||||
/* Touch gamebook: if a story pack is on the SD (/sdcard/gamebook/),
|
||||
* take over the screen with a tap-to-choose "livre dont tu es le
|
||||
* héros". No-op (and the phone UI stays) when no pack is present. */
|
||||
gamebook_init();
|
||||
}
|
||||
|
||||
/* Start the ESP-NOW receiver so the master can relay scenarios to us even
|
||||
|
||||
@@ -38,7 +38,7 @@ typedef struct {
|
||||
char reason[32]; /* "pickup" | "hangup" | "ring_timeout" | "dial:<n>" */
|
||||
} hook_event_t;
|
||||
|
||||
static i2s_chan_handle_t s_spk;
|
||||
static esp_codec_dev_handle_t s_spk;
|
||||
static volatile plip_hook_state_t s_state = PLIP_HOOK_ON;
|
||||
static volatile bool s_ring_stop = false;
|
||||
static TaskHandle_t s_ring_task = NULL;
|
||||
@@ -107,7 +107,6 @@ static void ring_burst(int duration_ms)
|
||||
{
|
||||
const int total = AUDIO_SAMPLE_RATE * duration_ms / 1000;
|
||||
int16_t buffer[256];
|
||||
size_t written = 0;
|
||||
int idx = 0;
|
||||
while (idx < total && !s_ring_stop) {
|
||||
int chunk = (total - idx < 256) ? (total - idx) : 256;
|
||||
@@ -116,8 +115,9 @@ static void ring_burst(int duration_ms)
|
||||
buffer[i] = (int16_t) (RING_AMPLITUDE *
|
||||
sinf(2.0f * (float) M_PI * RING_FREQ_HZ * t));
|
||||
}
|
||||
i2s_channel_write(s_spk, buffer, chunk * sizeof(int16_t), &written,
|
||||
pdMS_TO_TICKS(500));
|
||||
if (s_spk) {
|
||||
esp_codec_dev_write(s_spk, buffer, chunk * sizeof(int16_t));
|
||||
}
|
||||
idx += chunk;
|
||||
}
|
||||
}
|
||||
@@ -279,7 +279,7 @@ plip_hook_state_t plip_virtual_state(void)
|
||||
return s_state;
|
||||
}
|
||||
|
||||
esp_err_t plip_virtual_init(httpd_handle_t server, i2s_chan_handle_t spk)
|
||||
esp_err_t plip_virtual_init(httpd_handle_t server, esp_codec_dev_handle_t spk)
|
||||
{
|
||||
if (!server || !spk) return ESP_ERR_INVALID_ARG;
|
||||
s_spk = spk;
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include "driver/i2s_std.h"
|
||||
#include "esp_codec_dev.h"
|
||||
#include "esp_err.h"
|
||||
#include "esp_http_server.h"
|
||||
|
||||
@@ -34,7 +34,7 @@ typedef enum {
|
||||
// Register the REST handlers on `server` and keep `spk` for the ring tone.
|
||||
// Call once after scenario_server_start(); the speaker channel must be
|
||||
// enabled (speaker_init() in main.c).
|
||||
esp_err_t plip_virtual_init(httpd_handle_t server, i2s_chan_handle_t spk);
|
||||
esp_err_t plip_virtual_init(httpd_handle_t server, esp_codec_dev_handle_t spk);
|
||||
|
||||
// Forward a BOOT-button press. Returns true when the press was consumed as
|
||||
// a hook transition (pickup/hangup) — the caller should then skip its own
|
||||
|
||||
@@ -248,6 +248,30 @@ 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 lv_obj_t *s_gb_body_cont; // scrollable container holding s_gb_body
|
||||
static char s_gb_title_buf[64];
|
||||
static char s_gb_body_buf[1100];
|
||||
static char s_gb_menu_buf[160];
|
||||
static volatile int s_gb_scroll_req = 0; // pending scroll: +pages down / -pages up
|
||||
static volatile bool s_gb_scroll_home = false; // reset scroll to top on new passage
|
||||
|
||||
// ── Gamebook library (tile grid) ────────────────────────────────────────────
|
||||
#define LIB_TILES 6
|
||||
static lv_obj_t *s_scr_library;
|
||||
static lv_obj_t *s_lib_tile[LIB_TILES];
|
||||
static lv_obj_t *s_lib_label[LIB_TILES];
|
||||
static volatile bool s_library_open = false;
|
||||
static char s_lib_title_buf[LIB_TILES][48];
|
||||
static int s_lib_count_buf = 0;
|
||||
static int s_lib_sel_buf = 0;
|
||||
|
||||
static volatile bool s_browser_reload = false;
|
||||
|
||||
// ─── Intro — faithful port of the original cracktro ──────────────────────────
|
||||
@@ -761,6 +785,93 @@ scroller:
|
||||
lv_obj_set_pos(s_intro_scroll, sx, sy);
|
||||
}
|
||||
|
||||
static void build_library_screen(void) {
|
||||
s_scr_library = lv_obj_create(NULL);
|
||||
lv_obj_set_style_bg_color(s_scr_library, lv_color_hex(0x000000), 0);
|
||||
lv_obj_set_style_bg_opa(s_scr_library, LV_OPA_COVER, 0);
|
||||
lv_obj_clear_flag(s_scr_library, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
lv_obj_t *hdr = lv_label_create(s_scr_library);
|
||||
lv_obj_set_style_text_color(hdr, lv_color_hex(COL_CODE), 0);
|
||||
lv_obj_set_style_text_font(hdr, &lv_font_montserrat_14, 0);
|
||||
lv_label_set_text(hdr, "Choisis ton histoire");
|
||||
lv_obj_align(hdr, LV_ALIGN_TOP_MID, 0, 4);
|
||||
|
||||
for (int i = 0; i < LIB_TILES; i++) {
|
||||
int col = i % 2, row = i / 2;
|
||||
lv_obj_t *t = lv_obj_create(s_scr_library);
|
||||
lv_obj_set_size(t, 228, 84);
|
||||
lv_obj_set_pos(t, 8 + col * 236, 26 + row * 94);
|
||||
lv_obj_set_style_radius(t, 8, 0);
|
||||
lv_obj_set_style_bg_color(t, lv_color_hex(0x101820), 0);
|
||||
lv_obj_set_style_bg_opa(t, LV_OPA_COVER, 0);
|
||||
lv_obj_set_style_border_width(t, 2, 0);
|
||||
lv_obj_set_style_border_color(t, lv_color_hex(0x445566), 0);
|
||||
lv_obj_set_style_pad_all(t, 6, 0);
|
||||
lv_obj_clear_flag(t, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_t *l = lv_label_create(t);
|
||||
lv_obj_set_style_text_color(l, lv_color_hex(COL_VALUE), 0);
|
||||
lv_obj_set_style_text_font(l, &lv_font_montserrat_14, 0);
|
||||
lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_set_width(l, 208);
|
||||
lv_obj_set_style_text_align(l, LV_TEXT_ALIGN_CENTER, 0);
|
||||
lv_label_set_text(l, "");
|
||||
lv_obj_center(l);
|
||||
s_lib_tile[i] = t;
|
||||
s_lib_label[i] = l;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Compact title line at the very top.
|
||||
s_gb_title = lv_label_create(s_scr_gamebook);
|
||||
lv_obj_set_style_text_color(s_gb_title, lv_color_hex(COL_CODE), 0);
|
||||
lv_obj_set_style_text_font(s_gb_title, &lv_font_montserrat_14, 0);
|
||||
lv_label_set_long_mode(s_gb_title, LV_LABEL_LONG_DOT);
|
||||
lv_obj_set_width(s_gb_title, DUI_HOR_RES - 12);
|
||||
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, 4);
|
||||
|
||||
// Choice selector: a single line pinned at the very bottom. Left/Right cycle
|
||||
// the selection, click confirms. Drawn last (foreground).
|
||||
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(0x182230), 0);
|
||||
lv_obj_set_style_bg_opa(s_gb_menu, LV_OPA_COVER, 0);
|
||||
lv_obj_set_style_pad_all(s_gb_menu, 5, 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_DOT);
|
||||
lv_obj_set_width(s_gb_menu, DUI_HOR_RES);
|
||||
lv_obj_set_style_text_align(s_gb_menu, LV_TEXT_ALIGN_CENTER, 0);
|
||||
lv_label_set_text(s_gb_menu, "");
|
||||
lv_obj_align(s_gb_menu, LV_ALIGN_BOTTOM_MID, 0, 0);
|
||||
|
||||
// Reading area: a vertically-scrollable container filling the whole height
|
||||
// between the title and the choice line. Up/Down scroll it by half-pages.
|
||||
s_gb_body_cont = lv_obj_create(s_scr_gamebook);
|
||||
lv_obj_set_style_bg_color(s_gb_body_cont, lv_color_hex(0x000000), 0);
|
||||
lv_obj_set_style_bg_opa(s_gb_body_cont, LV_OPA_COVER, 0);
|
||||
lv_obj_set_style_border_width(s_gb_body_cont, 0, 0);
|
||||
lv_obj_set_style_pad_all(s_gb_body_cont, 6, 0);
|
||||
lv_obj_set_size(s_gb_body_cont, DUI_HOR_RES, 264); // 24 (title) .. 288 (menu)
|
||||
lv_obj_set_pos(s_gb_body_cont, 0, 24);
|
||||
lv_obj_set_scroll_dir(s_gb_body_cont, LV_DIR_VER);
|
||||
lv_obj_set_scrollbar_mode(s_gb_body_cont, LV_SCROLLBAR_MODE_AUTO);
|
||||
|
||||
s_gb_body = lv_label_create(s_gb_body_cont);
|
||||
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_width(s_gb_body, DUI_HOR_RES - 24); // container width minus padding
|
||||
lv_label_set_text(s_gb_body, "");
|
||||
lv_obj_align(s_gb_body, LV_ALIGN_TOP_LEFT, 0, 0);
|
||||
}
|
||||
|
||||
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 +964,49 @@ 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, then
|
||||
// apply any pending manual scroll (Up/Down) on the reading container.
|
||||
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);
|
||||
if (s_gb_scroll_home) {
|
||||
lv_obj_scroll_to_y(s_gb_body_cont, 0, LV_ANIM_OFF);
|
||||
s_gb_scroll_home = false;
|
||||
}
|
||||
int req = s_gb_scroll_req;
|
||||
if (req != 0) {
|
||||
s_gb_scroll_req = 0;
|
||||
// +1 page = read further down (content moves up): negative dy.
|
||||
lv_obj_scroll_by(s_gb_body_cont, 0, -req * 130, LV_ANIM_ON);
|
||||
}
|
||||
}
|
||||
// Library grid: refresh tile titles + highlight the selected one.
|
||||
if (s_library_open) {
|
||||
for (int i = 0; i < LIB_TILES; i++) {
|
||||
if (i < s_lib_count_buf) {
|
||||
lv_label_set_text(s_lib_label[i], s_lib_title_buf[i]);
|
||||
lv_obj_clear_flag(s_lib_tile[i], LV_OBJ_FLAG_HIDDEN);
|
||||
} else {
|
||||
lv_obj_add_flag(s_lib_tile[i], LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
bool on = (i == s_lib_sel_buf);
|
||||
lv_obj_set_style_border_color(s_lib_tile[i],
|
||||
lv_color_hex(on ? COL_CODE : 0x445566), 0);
|
||||
lv_obj_set_style_border_width(s_lib_tile[i], on ? 4 : 2, 0);
|
||||
lv_obj_set_style_bg_color(s_lib_tile[i],
|
||||
lv_color_hex(on ? 0x223040 : 0x101820), 0);
|
||||
}
|
||||
}
|
||||
|
||||
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_library_open) {
|
||||
want = s_scr_library; // story picker owns the screen
|
||||
} 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 +1114,8 @@ static void display_task(void *arg) {
|
||||
build_status_screen();
|
||||
build_scene_screen();
|
||||
build_shell_screen();
|
||||
build_gamebook_screen();
|
||||
build_library_screen();
|
||||
build_browser_screen();
|
||||
build_intro_screen();
|
||||
|
||||
@@ -1145,9 +1297,67 @@ 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, bool reset_scroll) {
|
||||
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 : "");
|
||||
if (reset_scroll) s_gb_scroll_home = true; // new passage → back to top
|
||||
s_gamebook_open = true;
|
||||
s_dirty = true;
|
||||
if (s_mutex) xSemaphoreGive(s_mutex);
|
||||
}
|
||||
|
||||
extern "C" void display_ui_gamebook_scroll(int dir) {
|
||||
// dir > 0 : read further down ; dir < 0 : back up. Accumulated and applied
|
||||
// on the display task (this runs on the buttons task).
|
||||
s_gb_scroll_req += (dir > 0) ? 1 : -1;
|
||||
s_dirty = true;
|
||||
}
|
||||
|
||||
extern "C" void display_ui_gamebook_hide(void) {
|
||||
s_gamebook_open = false;
|
||||
s_gb_scroll_req = 0;
|
||||
s_dirty = true;
|
||||
}
|
||||
|
||||
extern "C" void display_ui_library_show(const char *const *titles, int count,
|
||||
int sel) {
|
||||
if (count > LIB_TILES) count = LIB_TILES;
|
||||
if (count < 0) count = 0;
|
||||
if (s_mutex) xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||||
s_lib_count_buf = count;
|
||||
s_lib_sel_buf = (count > 0) ? ((sel % count + count) % count) : 0;
|
||||
for (int i = 0; i < LIB_TILES; i++) {
|
||||
snprintf(s_lib_title_buf[i], sizeof(s_lib_title_buf[i]), "%s",
|
||||
(i < count && titles && titles[i]) ? titles[i] : "");
|
||||
}
|
||||
s_library_open = true;
|
||||
s_gamebook_open = false; // library and a running story are exclusive
|
||||
s_dirty = true;
|
||||
if (s_mutex) xSemaphoreGive(s_mutex);
|
||||
}
|
||||
|
||||
extern "C" void display_ui_library_hide(void) {
|
||||
s_library_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,59 @@ 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, bool reset_scroll);
|
||||
|
||||
/**
|
||||
* @brief Scroll the gamebook reading area (Up/Down buttons).
|
||||
* @param dir >0 = read further down, <0 = back up. Half-page per call.
|
||||
*/
|
||||
void display_ui_gamebook_scroll(int dir);
|
||||
|
||||
/** @brief Leave the gamebook view and return to the normal scene/status flow. */
|
||||
void display_ui_gamebook_hide(void);
|
||||
|
||||
/**
|
||||
* @brief Show the gamebook library as a grid of up to 6 tiles.
|
||||
*
|
||||
* Each tile shows a story title; the tile at index `sel` is highlighted. The
|
||||
* gamebook owns the pad and calls this on every cursor move. Forces the
|
||||
* library view until a story is loaded or display_ui_library_hide(). Titles
|
||||
* should be ASCII.
|
||||
*
|
||||
* @param titles array of `count` story titles
|
||||
* @param count number of stories (clamped to 6)
|
||||
* @param sel highlighted tile index
|
||||
*/
|
||||
void display_ui_library_show(const char *const *titles, int count, int sel);
|
||||
|
||||
/** @brief Leave the library view. */
|
||||
void display_ui_library_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"}.
|
||||
@@ -1138,6 +1173,43 @@ static esp_err_t handle_file_post(httpd_req_t *req) {
|
||||
return send_json(req, "200 OK", resp);
|
||||
}
|
||||
|
||||
// ─── DELETE /game/file?path=sd/<…>|apps/<…> — remove a staged file ───────────
|
||||
// Same path routing/whitelist as the POST handler. Lets the host clean stale
|
||||
// assets (e.g. orphan gamebook WAVs) off the SD without reflashing.
|
||||
static esp_err_t handle_file_delete(httpd_req_t *req) {
|
||||
char query[160], path_param[96];
|
||||
if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK ||
|
||||
httpd_query_key_value(query, "path", path_param,
|
||||
sizeof(path_param)) != ESP_OK) {
|
||||
return send_error(req, "400 Bad Request", "missing path param");
|
||||
}
|
||||
const bool to_sd = (strncmp(path_param, "sd/", 3) == 0);
|
||||
const bool to_apps = (strncmp(path_param, "apps/", 5) == 0);
|
||||
if ((!to_sd && !to_apps) || strstr(path_param, "..") ||
|
||||
path_param[strlen(path_param) - 1] == '/') {
|
||||
return send_error(req, "403 Forbidden", "path must be under apps/ or sd/");
|
||||
}
|
||||
char full[192];
|
||||
if (to_sd) {
|
||||
if (!sd_storage_ready()) {
|
||||
return send_error(req, "503 Service Unavailable", "sd_not_mounted");
|
||||
}
|
||||
snprintf(full, sizeof(full), "/sdcard/%s", path_param + 3);
|
||||
} else {
|
||||
if (mount_storage_lazy() != ESP_OK) {
|
||||
return send_error(req, "503 Service Unavailable", "storage_unavailable");
|
||||
}
|
||||
snprintf(full, sizeof(full), "/littlefs/%s", path_param);
|
||||
}
|
||||
if (unlink(full) != 0) {
|
||||
return send_error(req, "404 Not Found", "remove failed");
|
||||
}
|
||||
ESP_LOGI(TAG, "file removed: %s", full);
|
||||
char resp[192];
|
||||
snprintf(resp, sizeof(resp), "{\"status\":\"ok\",\"removed\":\"%s\"}", path_param);
|
||||
return send_json(req, "200 OK", resp);
|
||||
}
|
||||
|
||||
void game_endpoint_get_scene(scene_binding_t *out) {
|
||||
if (!out) return;
|
||||
// Lock-free snapshot, same class as get_puzzle_status: written on the
|
||||
@@ -1209,6 +1281,12 @@ esp_err_t game_endpoint_init(httpd_handle_t server) {
|
||||
.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_espnow_cmd_post = {
|
||||
.uri = "/game/espnow/cmd",
|
||||
.method = HTTP_POST,
|
||||
@@ -1221,6 +1299,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
|
||||
@@ -1285,6 +1369,10 @@ esp_err_t game_endpoint_init(httpd_handle_t server) {
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "register POST /game/step: %s", esp_err_to_name(err));
|
||||
}
|
||||
err = httpd_register_uri_handler(server, &uri_file_delete);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "register DELETE /game/file: %s", esp_err_to_name(err));
|
||||
}
|
||||
err = httpd_register_uri_handler(server, &uri_file_post);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "register /game/file: %s", esp_err_to_name(err));
|
||||
@@ -1297,6 +1385,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
|
||||
)
|
||||
@@ -0,0 +1,306 @@
|
||||
// gamebook.c — see gamebook.h. Standalone gamebook player + library for the
|
||||
// Freenove master. On boot it shows the library (a grid of story tiles);
|
||||
// picking one loads and plays that book; finishing returns to the library.
|
||||
#include "gamebook.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_heap_caps.h"
|
||||
|
||||
#include "display_ui.h"
|
||||
#include "media_manager.h"
|
||||
|
||||
static const char *TAG = "gamebook";
|
||||
|
||||
#define GAMEBOOK_DIR "/sdcard/gamebook"
|
||||
#define LIBRARY_JSON GAMEBOOK_DIR "/library.json"
|
||||
#define JSON_MAX (256 * 1024) /* expanded books are ~80 KB of JSON */
|
||||
#define LIB_MAX 6 /* matches the display tile grid */
|
||||
|
||||
typedef enum { GB_OFF, GB_LIBRARY, GB_STORY } gb_mode_t;
|
||||
|
||||
// Library state
|
||||
static cJSON *s_lib_root = NULL; /* owns library.json while loaded */
|
||||
static cJSON *s_lib = NULL; /* borrowed: root->"library" array */
|
||||
static int s_lib_n = 0;
|
||||
static int s_lib_sel = 0;
|
||||
|
||||
// Story state
|
||||
static cJSON *s_book = NULL; /* owns the current <book>.json */
|
||||
static cJSON *s_passages = NULL; /* borrowed: book->"passages" */
|
||||
static char s_title[48] = {0};
|
||||
static char s_current[48] = {0};
|
||||
static int s_sel = 0; /* highlighted choice index */
|
||||
|
||||
static volatile gb_mode_t s_mode = GB_OFF;
|
||||
|
||||
bool gamebook_active(void) { return s_mode != GB_OFF; }
|
||||
|
||||
/* PSRAM allocators for cJSON — book trees are large (see gamebook_init). */
|
||||
static void *gb_malloc(size_t sz) { return heap_caps_malloc(sz, MALLOC_CAP_SPIRAM); }
|
||||
static void gb_free(void *p) { heap_caps_free(p); }
|
||||
|
||||
/* Read a whole JSON file from the SD into a cJSON tree (caller frees). */
|
||||
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 > JSON_MAX) { fclose(f); ESP_LOGW(TAG, "%s size %ld", path, sz); return NULL; }
|
||||
char *buf = heap_caps_malloc((size_t)sz + 1, MALLOC_CAP_SPIRAM);
|
||||
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);
|
||||
heap_caps_free(buf);
|
||||
if (!root) ESP_LOGW(TAG, "malformed JSON: %s", path);
|
||||
return root;
|
||||
}
|
||||
|
||||
// ── Library ──────────────────────────────────────────────────────────────────
|
||||
|
||||
static void render_library(void)
|
||||
{
|
||||
const char *titles[LIB_MAX] = {0};
|
||||
int n = (s_lib_n < LIB_MAX) ? s_lib_n : LIB_MAX;
|
||||
for (int i = 0; i < n; i++) {
|
||||
const cJSON *t = cJSON_GetObjectItem(cJSON_GetArrayItem(s_lib, i), "title");
|
||||
titles[i] = cJSON_IsString(t) ? t->valuestring : "?";
|
||||
}
|
||||
display_ui_library_show(titles, n, s_lib_sel);
|
||||
}
|
||||
|
||||
static void free_story(void)
|
||||
{
|
||||
media_manager_stop();
|
||||
if (s_book) { cJSON_Delete(s_book); s_book = NULL; }
|
||||
s_passages = NULL;
|
||||
s_current[0] = '\0';
|
||||
}
|
||||
|
||||
static esp_err_t open_library(void)
|
||||
{
|
||||
free_story();
|
||||
if (s_lib_root) { cJSON_Delete(s_lib_root); s_lib_root = NULL; s_lib = NULL; }
|
||||
|
||||
s_lib_root = load_json(LIBRARY_JSON);
|
||||
if (!s_lib_root) return ESP_ERR_NOT_FOUND;
|
||||
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;
|
||||
ESP_LOGW(TAG, "library.json has no stories");
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
s_lib_n = cJSON_GetArraySize(s_lib);
|
||||
s_lib_sel = 0;
|
||||
s_mode = GB_LIBRARY;
|
||||
display_ui_gamebook_hide();
|
||||
render_library();
|
||||
ESP_LOGI(TAG, "library open (%d stories)", s_lib_n);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// ── Story ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
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. `home` true = new passage (reset scroll to top);
|
||||
* false = same passage, only the choice cursor moved (keep scroll position).
|
||||
* The bottom line shows ONE choice with < > arrows (Left/Right cycles). */
|
||||
static void render_page(bool home)
|
||||
{
|
||||
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[160];
|
||||
if (n == 0) {
|
||||
snprintf(menu, sizeof(menu), "~ Fin ~ (clic = bibliotheque)");
|
||||
} else {
|
||||
const cJSON *lbl = cJSON_GetObjectItem(cJSON_GetArrayItem(choices, s_sel),
|
||||
"label");
|
||||
const char *txt = cJSON_IsString(lbl) ? lbl->valuestring : "?";
|
||||
if (n > 1) {
|
||||
/* Down cycles the answer, Click validates. */
|
||||
snprintf(menu, sizeof(menu), "%d/%d %s (bas / clic)",
|
||||
s_sel + 1, n, txt);
|
||||
} else {
|
||||
snprintf(menu, sizeof(menu), "%s (clic)", txt);
|
||||
}
|
||||
}
|
||||
display_ui_gamebook_show(
|
||||
cJSON_IsString(screen) ? screen->valuestring : s_title,
|
||||
cJSON_IsString(text) ? text->valuestring : "",
|
||||
menu, home);
|
||||
}
|
||||
|
||||
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(true);
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
/* Load story #idx from the library and play it. */
|
||||
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)) { ESP_LOGW(TAG, "library[%d] has no book", idx); return; }
|
||||
|
||||
char path[128];
|
||||
snprintf(path, sizeof(path), "%s/%s", GAMEBOOK_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");
|
||||
const cJSON *title = cJSON_GetObjectItem(book, "title");
|
||||
if (!cJSON_IsObject(passages) || !cJSON_IsString(start)) {
|
||||
ESP_LOGW(TAG, "%s missing passages/start", path);
|
||||
cJSON_Delete(book);
|
||||
return;
|
||||
}
|
||||
free_story();
|
||||
s_book = book;
|
||||
s_passages = (cJSON *)passages;
|
||||
snprintf(s_title, sizeof(s_title), "%s",
|
||||
cJSON_IsString(title) ? title->valuestring : "");
|
||||
s_mode = GB_STORY;
|
||||
display_ui_library_hide();
|
||||
ESP_LOGI(TAG, "load book \"%s\" @ '%s'", s_title, start->valuestring);
|
||||
enter_passage(start->valuestring);
|
||||
}
|
||||
|
||||
// ── Input ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/* MEASURED pad mapping on this hardware (ADC ladder, see calibration):
|
||||
* key1 = CLICK (centre, ~0 mV)
|
||||
* key2 = LEFT (gauche, ~700 mV)
|
||||
* key4 = DOWN (bas, ~1350 mV)
|
||||
* key5 = RIGHT (droite, ~1994 mV)
|
||||
* key3 is never produced; the physical UP button is electrically dead
|
||||
* (held = no voltage change), so nothing can depend on it. */
|
||||
#define PAD_CLICK 1
|
||||
#define PAD_LEFT 2
|
||||
#define PAD_DOWN 4
|
||||
#define PAD_RIGHT 5
|
||||
|
||||
/* Library nav: Down/Right → next tile, Left → previous, Click → open.
|
||||
* Selection wraps, so the working buttons reach every story without Up. */
|
||||
static bool library_key(uint8_t key)
|
||||
{
|
||||
if (s_lib_n <= 0) return true;
|
||||
switch (key) {
|
||||
case PAD_DOWN: case PAD_RIGHT:
|
||||
s_lib_sel = (s_lib_sel + 1) % s_lib_n; render_library(); break;
|
||||
case PAD_LEFT:
|
||||
s_lib_sel = (s_lib_sel - 1 + s_lib_n) % s_lib_n; render_library(); break;
|
||||
case PAD_CLICK:
|
||||
load_book(s_lib_sel); break;
|
||||
default: break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Story nav (Up is dead, so it's never used):
|
||||
* Left → scroll the reading text UP
|
||||
* Right → scroll the reading text DOWN
|
||||
* Down → next answer (cycles through all choices, wraps around)
|
||||
* Click → validate the highlighted answer
|
||||
* On an ending page (no choices): Left/Right still scroll, Click → library. */
|
||||
static bool story_key(uint8_t key)
|
||||
{
|
||||
if (key == PAD_LEFT) { display_ui_gamebook_scroll(-1); return true; } /* scroll up */
|
||||
if (key == PAD_RIGHT) { display_ui_gamebook_scroll(+1); return true; } /* scroll down */
|
||||
|
||||
int n = cur_choice_count();
|
||||
if (n == 0) { /* ending → click returns to library */
|
||||
if (key == PAD_CLICK) open_library();
|
||||
return true;
|
||||
}
|
||||
switch (key) {
|
||||
case PAD_DOWN: s_sel = (s_sel + 1) % n; render_page(false); break; /* next answer */
|
||||
case PAD_CLICK: { /* validate */
|
||||
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, "validate #%d -> '%s'", s_sel, g->valuestring);
|
||||
enter_passage(g->valuestring);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool gamebook_key_hook(uint8_t key)
|
||||
{
|
||||
switch (s_mode) {
|
||||
case GB_LIBRARY: return library_key(key);
|
||||
case GB_STORY: return story_key(key);
|
||||
default: return false; /* not active → let the shell have it */
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
esp_err_t gamebook_start(void)
|
||||
{
|
||||
return open_library();
|
||||
}
|
||||
|
||||
void gamebook_stop(void)
|
||||
{
|
||||
if (s_mode == GB_OFF) return;
|
||||
s_mode = GB_OFF;
|
||||
free_story();
|
||||
if (s_lib_root) { cJSON_Delete(s_lib_root); s_lib_root = NULL; s_lib = NULL; }
|
||||
display_ui_gamebook_hide();
|
||||
display_ui_library_hide();
|
||||
ESP_LOGI(TAG, "stopped");
|
||||
}
|
||||
|
||||
void gamebook_init(void)
|
||||
{
|
||||
/* Keep the large book trees in PSRAM — an expanded book is ~80 KB of JSON,
|
||||
* whose parsed node tree would exhaust the internal heap on this firmware. */
|
||||
static cJSON_Hooks hooks = {
|
||||
.malloc_fn = gb_malloc,
|
||||
.free_fn = gb_free,
|
||||
};
|
||||
cJSON_InitHooks(&hooks);
|
||||
|
||||
/* Only register the pad hook here — the SD and media_manager aren't up yet
|
||||
* at this point in boot. main.c calls gamebook_start() once they are, to
|
||||
* boot into the library. */
|
||||
display_ui_set_key_hook(gamebook_key_hook);
|
||||
ESP_LOGI(TAG, "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
|
||||
|
||||
@@ -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 = 24; // ota + voice_hook + game (incl. /game/step,
|
||||
// /game/puzzle_state, /game/file POST+DELETE,
|
||||
// relay, /game/gamebook)
|
||||
// + headroom
|
||||
config.uri_match_fn = httpd_uri_match_wildcard;
|
||||
config.stack_size = 8192;
|
||||
|
||||
@@ -23,6 +23,7 @@ idf_component_register(
|
||||
esp_event
|
||||
espressif__mdns
|
||||
display_ui
|
||||
gamebook
|
||||
puzzle_state
|
||||
p7_coffre
|
||||
p5_morse
|
||||
|
||||
@@ -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();
|
||||
@@ -514,6 +523,13 @@ void app_main(void) {
|
||||
ESP_LOGI(TAG, "media smoke play -> %s",
|
||||
esp_err_to_name(play_err));
|
||||
|
||||
// Boot into the gamebook library (the 6-story tile picker) when the
|
||||
// SD pack is present. Best-effort: no /sdcard/gamebook/library.json
|
||||
// -> the normal shell stays in charge.
|
||||
if (gamebook_start() == ESP_OK) {
|
||||
ESP_LOGI(TAG, "gamebook library up (boot picker)");
|
||||
}
|
||||
|
||||
// Slice 4: bring up the ported npc_engine. Cue table is empty
|
||||
// at this stage — wiring the scenario IR-driven cue catalog is
|
||||
// a follow-up slice. The engine still boots, accepts ticks
|
||||
@@ -530,6 +546,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
|
||||
|
||||
@@ -10,8 +10,10 @@ idf_component_register(
|
||||
"tones.c"
|
||||
"dialer.c"
|
||||
"conversation.c"
|
||||
"dtmf.c"
|
||||
"turn_client.c"
|
||||
"slic.c"
|
||||
"plip_gamebook.c"
|
||||
INCLUDE_DIRS "."
|
||||
PRIV_REQUIRES
|
||||
driver
|
||||
|
||||
@@ -31,7 +31,7 @@ menu "PLIP Voice Configuration"
|
||||
|
||||
config PLIP_SPEAKER_VOLUME
|
||||
int "Default Speaker Volume (0-100)"
|
||||
default 70
|
||||
default 98
|
||||
range 0 100
|
||||
help
|
||||
Default speaker output volume at boot.
|
||||
@@ -71,6 +71,28 @@ menu "PLIP Voice Configuration"
|
||||
a pulse train, the train is considered complete and the digit is
|
||||
emitted. 200 ms is standard for French rotary dials.
|
||||
|
||||
config PLIP_DIAL_DTMF
|
||||
bool "Enable DTMF (touch-tone) dialing via Goertzel"
|
||||
default n
|
||||
help
|
||||
When enabled, a background task reads 20 ms microphone frames and
|
||||
runs a Goertzel-based DTMF detector (8 frequencies: 697-1633 Hz).
|
||||
Confirmed digits (≥ 40 ms tone, with twist and dominance guards)
|
||||
are pushed to the dialer just like rotary pulses.
|
||||
The detector is active only between off-hook and the start of the
|
||||
NPC greeting; it is disarmed during voice capture (CONNECTED state).
|
||||
Can be combined with PLIP_DIAL_PULSE: whichever source detects a
|
||||
digit first wins. Default off — enable for touch-tone handsets.
|
||||
|
||||
config PLIP_AUTO_RING
|
||||
bool "Auto story-ring (rings by itself every 15-30 min)"
|
||||
default n
|
||||
help
|
||||
When enabled, the phone rings on its own at a random 15-30 minute
|
||||
interval; picking up launches a random audio story straight away
|
||||
(no menu). If nobody answers within 1 minute the bell stops and the
|
||||
next ring is rescheduled. Default off — enable for the escape-room
|
||||
ambience where the phone calls the players.
|
||||
|
||||
config PLIP_GATEWAY_URL
|
||||
string "NPC Gateway Base URL"
|
||||
@@ -88,4 +110,22 @@ menu "PLIP Voice Configuration"
|
||||
Bearer token sent as "Authorization: Bearer <token>" on every
|
||||
/v1/voice/turn request. Leave empty to skip the header.
|
||||
|
||||
config PLIP_VOICE_REPLY
|
||||
bool "Enable Stage-3 conversational LISTEN loop (capture -> /v1/voice/reply -> play)"
|
||||
default n
|
||||
help
|
||||
When enabled, after the NPC greeting is played (STATE_CONNECTED),
|
||||
the firmware enters a continuous listen loop:
|
||||
1. Capture mic audio (up to 8 s, VAD-gated) via audio_capture_wav().
|
||||
2. POST the captured WAV as multipart/form-data to
|
||||
CONFIG_PLIP_GATEWAY_URL/v1/voice/reply (STT + NPC reply via Kyutai).
|
||||
3. Play the NPC response WAV from /spiffs/reply.wav.
|
||||
4. Repeat until the handset is hung up.
|
||||
Requires the gateway (zacus-gateway FastAPI) to be reachable and
|
||||
the /v1/voice/reply endpoint to be operational.
|
||||
Capture buffer (~256 KB for 8 s) is allocated from PSRAM when
|
||||
available; falls back to internal heap with reduced duration (4 s).
|
||||
Leave OFF (default) to keep STATE_CONNECTED as a terminal state
|
||||
(Stage 2 behaviour — greeting only, no further interaction).
|
||||
|
||||
endmenu
|
||||
|
||||
+174
-100
@@ -18,6 +18,7 @@
|
||||
#include "board_config.h"
|
||||
#include "es8388.h"
|
||||
#include "phone.h"
|
||||
#include "slic.h"
|
||||
|
||||
#include <inttypes.h>
|
||||
#include <math.h>
|
||||
@@ -69,6 +70,7 @@ static QueueHandle_t s_queue;
|
||||
static i2s_chan_handle_t s_spk_handle = NULL;
|
||||
static i2s_chan_handle_t s_mic_handle = NULL;
|
||||
static volatile bool s_stop_req = false;
|
||||
static volatile bool s_playing = false; /* true while the worker plays a clip */
|
||||
static bool s_sd_mounted = false;
|
||||
static bool s_spiffs_mounted = false;
|
||||
|
||||
@@ -81,7 +83,7 @@ static void ensure_spiffs_audio(void)
|
||||
.base_path = "/spiffs",
|
||||
.partition_label = "storage",
|
||||
.max_files = 8,
|
||||
.format_if_mount_failed = false,
|
||||
.format_if_mount_failed = true, /* format a blank/corrupt partition at boot */
|
||||
};
|
||||
esp_err_t ret = esp_vfs_spiffs_register(&conf);
|
||||
if (ret == ESP_OK || ret == ESP_ERR_INVALID_STATE /* already mounted */) {
|
||||
@@ -135,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)
|
||||
@@ -167,45 +175,6 @@ static esp_err_t parse_wav_header(const uint8_t *buf, size_t len, wav_info_t *ou
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* ── Streaming helpers ───────────────────────────────────────────────────── */
|
||||
|
||||
/* Stream raw PCM-16 data to the speaker I2S channel in chunks. */
|
||||
static void stream_pcm(const uint8_t *data, size_t byte_len)
|
||||
{
|
||||
size_t offset = 0;
|
||||
while (!s_stop_req && offset < byte_len) {
|
||||
size_t chunk = (byte_len - offset < 2048) ? (byte_len - offset) : 2048;
|
||||
size_t written = 0;
|
||||
esp_err_t ret = i2s_channel_write(s_spk_handle,
|
||||
data + offset, chunk,
|
||||
&written, pdMS_TO_TICKS(500));
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "I2S write error: %s", esp_err_to_name(ret));
|
||||
break;
|
||||
}
|
||||
offset += chunk;
|
||||
}
|
||||
}
|
||||
|
||||
/* Play WAV from an in-memory buffer. */
|
||||
static void play_wav_buf(const uint8_t *buf, size_t len)
|
||||
{
|
||||
wav_info_t wi = {0};
|
||||
esp_err_t ret = parse_wav_header(buf, len, &wi);
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "WAV parse error: %s", esp_err_to_name(ret));
|
||||
audio_play_tone(880.0f, 200);
|
||||
return;
|
||||
}
|
||||
if (wi.bits_per_sample != 16) {
|
||||
ESP_LOGW(TAG, "WAV: %d-bit not supported (need 16-bit)", wi.bits_per_sample);
|
||||
return;
|
||||
}
|
||||
ESP_LOGI(TAG, "WAV: %"PRIu32" Hz %d-bit %d ch, %"PRIu32" bytes PCM",
|
||||
wi.sample_rate, wi.bits_per_sample, wi.channels, wi.data_size);
|
||||
stream_pcm(buf + wi.data_offset, wi.data_size);
|
||||
}
|
||||
|
||||
/* WAV streaming chunk size — keeps heap usage well under 8 KB. */
|
||||
#define PLAY_CHUNK_BYTES 4096
|
||||
|
||||
@@ -279,28 +248,62 @@ static void play_wav_file(const char *path)
|
||||
return;
|
||||
}
|
||||
|
||||
/* Stream PCM to I2S in small chunks — no large malloc needed. */
|
||||
static uint8_t s_play_chunk[PLAY_CHUNK_BYTES]; /* static: avoids stack pressure */
|
||||
uint32_t remaining = wi.data_size;
|
||||
size_t total_written = 0;
|
||||
/* The I2S TX slot is STEREO @ SAMPLE_RATE. A mono WAV must be expanded to
|
||||
* L+R or it is consumed at 2x rate (the "chipmunk" fast/high-pitch bug).
|
||||
* A WAV whose rate differs from SAMPLE_RATE would also play at the wrong
|
||||
* speed — warn (the gateway TTS always returns 16 kHz, matching). */
|
||||
if (wi.sample_rate != SAMPLE_RATE) {
|
||||
ESP_LOGW(TAG, "WAV rate %"PRIu32" Hz != I2S %d Hz — playback speed will be off",
|
||||
wi.sample_rate, SAMPLE_RATE);
|
||||
}
|
||||
const bool mono = (wi.channels == 1);
|
||||
|
||||
while (!s_stop_req && remaining > 0) {
|
||||
uint32_t to_read = (remaining < PLAY_CHUNK_BYTES) ? remaining : PLAY_CHUNK_BYTES;
|
||||
size_t n = fread(s_play_chunk, 1, to_read, f);
|
||||
if (n == 0) break;
|
||||
size_t i2s_written = 0;
|
||||
esp_err_t ret = i2s_channel_write(s_spk_handle, s_play_chunk, n,
|
||||
&i2s_written, pdMS_TO_TICKS(500));
|
||||
/* Load the ENTIRE PCM into PSRAM BEFORE playing. Streaming fread() from
|
||||
* SPIFFS *between* I2S writes stalls the DMA → underrun → audible
|
||||
* distortion ("saturation"). Diagnostic confirmed: the stored WAV is clean
|
||||
* (0% clip) and RAM-generated tones play clean, but SPIFFS-streamed WAVs
|
||||
* distorted. Reading it all up-front = zero file I/O during playback. */
|
||||
uint8_t *pcm = heap_caps_malloc(wi.data_size, MALLOC_CAP_SPIRAM);
|
||||
if (!pcm) pcm = malloc(wi.data_size);
|
||||
if (!pcm) {
|
||||
ESP_LOGE(TAG, "play: OOM for %"PRIu32"-byte PCM buffer", wi.data_size);
|
||||
fclose(f);
|
||||
return;
|
||||
}
|
||||
size_t pcm_len = fread(pcm, 1, wi.data_size, f);
|
||||
fclose(f);
|
||||
|
||||
static int16_t s_stereo_chunk[PLAY_CHUNK_BYTES]; /* mono→stereo scratch */
|
||||
size_t off = 0;
|
||||
size_t total_written = 0;
|
||||
const size_t step = mono ? (PLAY_CHUNK_BYTES / 2) : PLAY_CHUNK_BYTES;
|
||||
|
||||
while (!s_stop_req && off < pcm_len) {
|
||||
size_t bytes = (pcm_len - off < step) ? (pcm_len - off) : step;
|
||||
const uint8_t *out = pcm + off;
|
||||
size_t out_len = bytes;
|
||||
if (mono) {
|
||||
/* Duplicate each 16-bit mono sample into L and R. */
|
||||
size_t samples = bytes / 2;
|
||||
const int16_t *src = (const int16_t *)(pcm + off);
|
||||
for (size_t k = 0; k < samples; k++) {
|
||||
s_stereo_chunk[2 * k] = src[k];
|
||||
s_stereo_chunk[2 * k + 1] = src[k];
|
||||
}
|
||||
out = (const uint8_t *)s_stereo_chunk;
|
||||
out_len = samples * 4; /* 2 channels × 2 bytes */
|
||||
}
|
||||
size_t w = 0;
|
||||
esp_err_t ret = i2s_channel_write(s_spk_handle, out, out_len, &w, pdMS_TO_TICKS(500));
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "I2S write error: %s", esp_err_to_name(ret));
|
||||
break;
|
||||
}
|
||||
total_written += i2s_written;
|
||||
remaining -= (uint32_t)n;
|
||||
total_written += w;
|
||||
off += bytes;
|
||||
}
|
||||
|
||||
fclose(f);
|
||||
float dur = (float)total_written / (float)(wi.sample_rate * wi.channels * (wi.bits_per_sample / 8));
|
||||
free(pcm);
|
||||
float dur = (float)total_written / (float)(SAMPLE_RATE * 2 * 2); /* 16k stereo 16-bit */
|
||||
ESP_LOGI(TAG, "play done: %zu bytes written, %.2fs", total_written, dur);
|
||||
}
|
||||
|
||||
@@ -380,11 +383,17 @@ 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;
|
||||
}
|
||||
s_playing = true;
|
||||
const char *p = cmd.path;
|
||||
if (!p || !*p || strncmp(p, "embedded:", 9) == 0) {
|
||||
ESP_LOGI(TAG, "play: embedded cue");
|
||||
@@ -393,6 +402,7 @@ static void audio_worker_task(void *arg)
|
||||
ESP_LOGI(TAG, "play: %s", p);
|
||||
play_wav_file(p);
|
||||
}
|
||||
s_playing = false;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
@@ -409,6 +419,11 @@ void audio_pa_set(bool enable)
|
||||
ESP_LOGI(TAG, "PA %s", enable ? "ON" : "OFF");
|
||||
}
|
||||
|
||||
bool audio_is_playing(void)
|
||||
{
|
||||
return s_playing;
|
||||
}
|
||||
|
||||
esp_err_t audio_init(void)
|
||||
{
|
||||
/* 1. ES8388 I2C init + register sequence. */
|
||||
@@ -418,6 +433,10 @@ esp_err_t audio_init(void)
|
||||
return ret;
|
||||
}
|
||||
|
||||
/* Apply the configured output volume. es8388_init() leaves OUT2 at 0 dB
|
||||
* (max) — too loud for a handset earpiece — so set it explicitly here. */
|
||||
es8388_set_volume(CONFIG_PLIP_SPEAKER_VOLUME);
|
||||
|
||||
/* 2. Create I2S channels (TX = speaker, RX = mic — full-duplex pair). */
|
||||
i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(PLIP_I2S_NUM,
|
||||
I2S_ROLE_MASTER);
|
||||
@@ -513,6 +532,10 @@ esp_err_t audio_init(void)
|
||||
8192, NULL, 5, NULL, 0);
|
||||
if (ok != pdPASS) return ESP_ERR_NO_MEM;
|
||||
|
||||
/* Mount SPIFFS at init (not lazily) so turn_client can WRITE the NPC WAV to
|
||||
* /spiffs before any playback has triggered the lazy mount. */
|
||||
ensure_spiffs_audio();
|
||||
|
||||
ESP_LOGI(TAG, "audio init OK (I2S TX ready, RX handle allocated, ES8388 live)");
|
||||
return ESP_OK;
|
||||
}
|
||||
@@ -564,9 +587,11 @@ int audio_capture_wav(uint8_t *out, size_t out_max, int max_ms, int silence_ms)
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Enable RX while keeping TX running (TX drives MCLK/BCLK/WS for the codec). */
|
||||
/* RX is already enabled at boot (full-duplex). Calling enable again returns
|
||||
* ESP_ERR_INVALID_STATE — that's fine, it just means RX is already running.
|
||||
* Only a genuinely different error is fatal. */
|
||||
esp_err_t ret = i2s_channel_enable(s_mic_handle);
|
||||
if (ret != ESP_OK) {
|
||||
if (ret != ESP_OK && ret != ESP_ERR_INVALID_STATE) {
|
||||
ESP_LOGE(TAG, "capture: i2s_channel_enable(RX): %s", esp_err_to_name(ret));
|
||||
free(rx_buf);
|
||||
return -1;
|
||||
@@ -580,67 +605,118 @@ 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. */
|
||||
}
|
||||
|
||||
/* Disable RX; TX was never stopped. */
|
||||
i2s_channel_disable(s_mic_handle);
|
||||
/* Leave RX enabled (full-duplex, as at boot). Disabling it here would make
|
||||
* the next capture's enable a no-op INVALID_STATE AND break other RX users
|
||||
* (streaming capture / DTMF) that assume RX stays running. */
|
||||
free(rx_buf);
|
||||
|
||||
if (pcm_written == 0) {
|
||||
@@ -706,8 +782,6 @@ int audio_capture_begin(int max_ms, int silence_ms)
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*
|
||||
* Read one 20 ms frame from the mic, downmix stereo→mono.
|
||||
* mono_out must hold n_samples (320) int16_t values.
|
||||
|
||||
@@ -39,6 +39,17 @@ 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). */
|
||||
bool audio_is_playing(void);
|
||||
|
||||
/* Start ring tone cadence (ON 1s / OFF 2s) at ~440 Hz. Continues until
|
||||
* audio_stop() is called. Non-blocking — spawns an internal task. */
|
||||
esp_err_t audio_ring_start(void);
|
||||
|
||||
+385
-15
@@ -20,16 +20,43 @@
|
||||
#include "dialer.h"
|
||||
#include "tones.h"
|
||||
#include "audio.h"
|
||||
#include "phone.h"
|
||||
#include "slic.h"
|
||||
#include "turn_client.h"
|
||||
#include "plip_gamebook.h"
|
||||
#if CONFIG_PLIP_DIAL_DTMF
|
||||
#include "dtmf.h"
|
||||
#endif
|
||||
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_random.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#if CONFIG_PLIP_VOICE_REPLY
|
||||
/* Capture buffer sizing.
|
||||
* PSRAM target: 8 s of 16kHz mono S16 + 44-byte WAV header.
|
||||
* 8 s × 16000 samples/s × 2 bytes = 256000 bytes + 44 = 256044 → round to 256 KB.
|
||||
* Fallback (internal heap, 4 s max):
|
||||
* 4 s × 16000 × 2 + 44 = 128044 → round to 128 KB.
|
||||
*/
|
||||
#define CAPTURE_MAX_PSRAM (256 * 1024)
|
||||
#define CAPTURE_MAX_IRAM (128 * 1024)
|
||||
#define CAPTURE_MAX_MS_PSRAM 8000
|
||||
#define CAPTURE_MAX_MS_IRAM 4000
|
||||
#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 */
|
||||
#endif /* CONFIG_PLIP_VOICE_REPLY */
|
||||
|
||||
#define TAG "conversation"
|
||||
|
||||
/* Duration of ringback before picking up and fetching the greeting */
|
||||
@@ -43,6 +70,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;
|
||||
@@ -54,6 +82,31 @@ static int64_t s_ringback_start_us = 0;
|
||||
|
||||
/* Session ID for the current call (generated at ringback → greet transition) */
|
||||
static char s_sid[32] = {0};
|
||||
/* Dialed number LOCKED at routing time. The dialer can keep accumulating
|
||||
* spurious rotary pulses (marginal hook contact) during the call, so we must
|
||||
* 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;
|
||||
|
||||
/* Auto story-ring: the phone rings on its own at a random interval; picking up
|
||||
* launches a random audio story straight away. If nobody answers, the bell
|
||||
* stops after STORY_RING_TIMEOUT_MS and the next ring is rescheduled. */
|
||||
#define STORY_RING_MIN_MS (15 * 60 * 1000) /* 15 min */
|
||||
#define STORY_RING_SPAN_MS (15 * 60 * 1000) /* + up to 15 min → 30 max */
|
||||
#define STORY_RING_TIMEOUT_MS (60 * 1000) /* give up after 1 min */
|
||||
static volatile bool s_story_ring_armed = false;
|
||||
static int64_t s_story_ring_start_us = 0;
|
||||
|
||||
/* Known numbers: ringback when dialed */
|
||||
static const char *KNOWN[] = {
|
||||
@@ -68,16 +121,111 @@ 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)
|
||||
{
|
||||
if (s_story_ring_armed) { /* hung up mid auto-ring → silence the bell */
|
||||
phone_ring_stop();
|
||||
slic_ring_stop();
|
||||
s_story_ring_armed = false;
|
||||
}
|
||||
tones_stop();
|
||||
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
|
||||
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);
|
||||
}
|
||||
|
||||
/* Answered an auto story-ring: stop the bell and launch a random story right
|
||||
* into STATE_GAMEBOOK (no menu). Drives s_offhook directly because the
|
||||
* debounced GPIO hook detection is unreliable while the bell rings. */
|
||||
static void enter_story_from_ring(void)
|
||||
{
|
||||
s_story_ring_armed = false;
|
||||
phone_ring_stop();
|
||||
slic_ring_stop();
|
||||
tones_stop();
|
||||
dialer_reset();
|
||||
s_offhook = true;
|
||||
#if CONFIG_PLIP_DIAL_DTMF
|
||||
dtmf_start();
|
||||
#endif
|
||||
audio_pa_set(true);
|
||||
plip_gamebook_begin_random();
|
||||
s_state = STATE_GAMEBOOK;
|
||||
ESP_LOGI(TAG, "story-ring pickup -> GAMEBOOK (random story)");
|
||||
}
|
||||
|
||||
#if CONFIG_PLIP_AUTO_RING
|
||||
/* Rings the phone at a random 15–30 min interval. Only rings when the line is
|
||||
* truly idle (on-hook, no other call/ring armed) and a story pack is present.
|
||||
* The 1-minute no-answer timeout is handled in conv_task. */
|
||||
static void story_ring_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
for (;;) {
|
||||
uint32_t wait_ms = STORY_RING_MIN_MS + (esp_random() % STORY_RING_SPAN_MS);
|
||||
ESP_LOGI(TAG, "auto story-ring: next attempt in %u min",
|
||||
(unsigned)(wait_ms / 60000));
|
||||
vTaskDelay(pdMS_TO_TICKS(wait_ms));
|
||||
|
||||
if (s_state == STATE_IDLE && !s_offhook && !s_incoming_armed
|
||||
&& !s_story_ring_armed && plip_gamebook_available()) {
|
||||
s_story_ring_armed = true;
|
||||
s_story_ring_start_us = esp_timer_get_time();
|
||||
phone_ring_start();
|
||||
ESP_LOGI(TAG, "auto story-ring: ringing (up to %d s)",
|
||||
STORY_RING_TIMEOUT_MS / 1000);
|
||||
} else {
|
||||
ESP_LOGI(TAG, "auto story-ring: line busy/unavailable — skipped");
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif /* CONFIG_PLIP_AUTO_RING */
|
||||
|
||||
static void conv_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
@@ -97,12 +245,35 @@ 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();
|
||||
s_state = STATE_DIALTONE;
|
||||
ESP_LOGI(TAG, "off-hook -> DIALTONE");
|
||||
if (s_story_ring_armed) {
|
||||
/* Answered an auto story-ring → straight into a story. */
|
||||
enter_story_from_ring();
|
||||
} else 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();
|
||||
tones_dialtone_start();
|
||||
#if CONFIG_PLIP_DIAL_DTMF
|
||||
dtmf_start();
|
||||
#endif
|
||||
s_state = STATE_DIALTONE;
|
||||
ESP_LOGI(TAG, "off-hook -> DIALTONE");
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -111,7 +282,26 @@ 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();
|
||||
} else if (s_story_ring_armed) {
|
||||
/* Same raw-SLIC poll for the auto story-ring, plus the
|
||||
* no-answer timeout: stop the bell after 1 min unanswered. */
|
||||
if (slic_is_offhook()) {
|
||||
enter_story_from_ring();
|
||||
} else if ((esp_timer_get_time() - s_story_ring_start_us) / 1000
|
||||
>= STORY_RING_TIMEOUT_MS) {
|
||||
phone_ring_stop();
|
||||
slic_ring_stop();
|
||||
s_story_ring_armed = false;
|
||||
ESP_LOGI(TAG, "auto story-ring: no answer after %d s — stop",
|
||||
STORY_RING_TIMEOUT_MS / 1000);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case STATE_DIALTONE:
|
||||
if (!s_offhook) {
|
||||
@@ -126,6 +316,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();
|
||||
@@ -136,6 +341,9 @@ static void conv_task(void *arg)
|
||||
const char *num = dialer_current();
|
||||
if (is_known(num)) {
|
||||
ESP_LOGI(TAG, "route %s -> known (ringback)", num);
|
||||
/* Lock the routed number now — the dialer may pick up
|
||||
* spurious pulses later and we must keep posting "17". */
|
||||
snprintf(s_number, sizeof(s_number), "%s", num);
|
||||
tones_ringback_start();
|
||||
s_ringback_start_us = esp_timer_get_time();
|
||||
s_state = STATE_RINGBACK;
|
||||
@@ -158,6 +366,10 @@ static void conv_task(void *arg)
|
||||
if (elapsed_ms >= RINGBACK_GREET_MS) {
|
||||
/* Stop ringback tone synchronously before fetching */
|
||||
tones_stop();
|
||||
#if CONFIG_PLIP_DIAL_DTMF
|
||||
/* Disarm DTMF before entering voice-capture phase */
|
||||
dtmf_stop();
|
||||
#endif
|
||||
/* Generate a session ID from timer ticks */
|
||||
snprintf(s_sid, sizeof(s_sid), "%lld",
|
||||
(long long)esp_timer_get_time());
|
||||
@@ -173,22 +385,159 @@ static void conv_task(void *arg)
|
||||
go_idle();
|
||||
break;
|
||||
}
|
||||
/* Fetch greeting WAV from gateway and enqueue playback */
|
||||
if (turn_client_greeting(s_sid, dialer_current(),
|
||||
"/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:
|
||||
/* Stage 3 will add listen/speak loop here */
|
||||
/* 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.
|
||||
*
|
||||
* Allocate capture buffer once from PSRAM (preferred) or internal
|
||||
* heap. Then loop: capture → POST reply → play → wait → repeat.
|
||||
* Exit on any on-hook event. Buffer freed before leaving.
|
||||
*/
|
||||
{
|
||||
/* --- Allocate capture buffer -------------------------------- */
|
||||
uint8_t *cap_buf = NULL;
|
||||
size_t cap_max = 0;
|
||||
int cap_ms = 0;
|
||||
|
||||
cap_buf = heap_caps_malloc(CAPTURE_MAX_PSRAM, MALLOC_CAP_SPIRAM);
|
||||
if (cap_buf) {
|
||||
cap_max = CAPTURE_MAX_PSRAM;
|
||||
cap_ms = CAPTURE_MAX_MS_PSRAM;
|
||||
ESP_LOGI(TAG, "listen: cap_buf %zu B from PSRAM", cap_max);
|
||||
} else {
|
||||
cap_buf = malloc(CAPTURE_MAX_IRAM);
|
||||
if (cap_buf) {
|
||||
cap_max = CAPTURE_MAX_IRAM;
|
||||
cap_ms = CAPTURE_MAX_MS_IRAM;
|
||||
ESP_LOGW(TAG, "listen: PSRAM unavail, cap_buf %zu B from heap (max %d s)",
|
||||
cap_max, cap_ms / 1000);
|
||||
} else {
|
||||
ESP_LOGE(TAG, "listen: cap_buf alloc failed — staying silent");
|
||||
/* Remain in CONNECTED without looping */
|
||||
if (!s_offhook) go_idle();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- LISTEN loop ------------------------------------------- */
|
||||
ESP_LOGI(TAG, "listen: entering loop (max %d s / silence %d ms)",
|
||||
cap_ms, CAPTURE_SILENCE_MS);
|
||||
|
||||
while (s_offhook) {
|
||||
/* HALF-DUPLEX: a telephone handset couples the earpiece into
|
||||
* 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(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)); /* let the I2S DMA tail drain */
|
||||
|
||||
/* 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 */
|
||||
|
||||
if (n <= 44) {
|
||||
ESP_LOGD(TAG, "listen: no voice (n=%d)", n);
|
||||
continue;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "listen: captured %d bytes, posting to gateway", n);
|
||||
|
||||
/* Filler "un instant, je traite votre demande" plays while the
|
||||
* reply is synthesised (the POST blocks for several seconds). */
|
||||
audio_play_async("/spiffs/wait.wav");
|
||||
|
||||
esp_err_t ret = turn_client_reply(s_sid, s_number,
|
||||
cap_buf, (size_t)n,
|
||||
"/spiffs/reply.wav");
|
||||
if (!s_offhook) break; /* hung up during HTTP round-trip */
|
||||
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "listen: turn_client_reply failed (%s) — skipping",
|
||||
esp_err_to_name(ret));
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Let the filler finish before the reply (no overlap), then play
|
||||
* the reply. The loop top waits for it to end before re-capturing. */
|
||||
while (s_offhook && audio_is_playing()) {
|
||||
vTaskDelay(pdMS_TO_TICKS(50));
|
||||
}
|
||||
if (!s_offhook) break;
|
||||
audio_play_async("/spiffs/reply.wav");
|
||||
}
|
||||
|
||||
/* --- Cleanup ----------------------------------------------- */
|
||||
free(cap_buf);
|
||||
ESP_LOGI(TAG, "listen: loop exited (offhook=%d)", (int)s_offhook);
|
||||
|
||||
if (!s_offhook) go_idle();
|
||||
}
|
||||
#else
|
||||
/* Stage 3 disabled — STATE_CONNECTED is terminal */
|
||||
if (!s_offhook) {
|
||||
go_idle();
|
||||
}
|
||||
#endif /* CONFIG_PLIP_VOICE_REPLY */
|
||||
break;
|
||||
|
||||
case STATE_BUSY:
|
||||
@@ -205,9 +554,18 @@ void conversation_init(void)
|
||||
s_state = STATE_IDLE;
|
||||
s_offhook = false;
|
||||
s_hook_changed = false;
|
||||
/* Stack bumped to 6144: STATE_GREET calls esp_http_client (blocking HTTP
|
||||
* + file I/O) which needs more stack than the baseline 3072. */
|
||||
/* Stack: STATE_GREET needs 6144 (esp_http_client + file I/O).
|
||||
* STATE_CONNECTED listen loop (Stage 3) adds turn_client_reply (~1100 B
|
||||
* locals) + stat() call → bump to 8192 when Stage 3 is compiled in. */
|
||||
#if CONFIG_PLIP_VOICE_REPLY
|
||||
xTaskCreate(conv_task, "conv", 8192, NULL, 4, NULL);
|
||||
#else
|
||||
xTaskCreate(conv_task, "conv", 6144, NULL, 4, NULL);
|
||||
#endif
|
||||
#if CONFIG_PLIP_AUTO_RING
|
||||
/* Auto story-ring scheduler: rings every 15–30 min when idle. */
|
||||
xTaskCreate(story_ring_task, "storyring", 3072, NULL, 3, NULL);
|
||||
#endif
|
||||
ESP_LOGI(TAG, "conversation init");
|
||||
}
|
||||
|
||||
@@ -216,3 +574,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 : "-");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
/*
|
||||
* dtmf.c — DTMF (touch-tone) detector using the Goertzel algorithm.
|
||||
*
|
||||
* Detection pipeline per 20 ms frame (320 samples @ 16 kHz):
|
||||
* 1. Compute Goertzel power for 8 DTMF frequencies (4 low + 4 high groups).
|
||||
* 2. Find the strongest frequency in each group (best_low, best_high).
|
||||
* 3. Apply three guards:
|
||||
* a) Absolute energy threshold — both must exceed DTMF_ENERGY_THRESH.
|
||||
* b) Group dominance ratio — winner must be > DTMF_DOMINANT_RATIO×
|
||||
* times the second-best in the same group.
|
||||
* c) Twist guard — energy ratio (low/high) must be within
|
||||
* [1/DTMF_TWIST_MAX, DTMF_TWIST_MAX].
|
||||
* 4. Debounce:
|
||||
* - Require DTMF_CONFIRM_FRAMES consecutive matching frames to emit.
|
||||
* - Require DTMF_RELEASE_FRAMES of silence/mismatch before re-arming.
|
||||
*
|
||||
* Threshold rationale:
|
||||
* DTMF_ENERGY_THRESH = 4000000
|
||||
* Goertzel power is mean-squared × N². A -30 dBFS sine at 16-bit PCM
|
||||
* (amplitude ≈ 1000 LSB) gives power ≈ (1000²/2) × 320² / 320 ≈ 1.6e8.
|
||||
* -50 dBFS (amplitude ≈ 100) gives ≈ 1.6e6. We set the floor at 4e6
|
||||
* (≈ -47 dBFS) to reject noise while allowing quiet handset microphones.
|
||||
*
|
||||
* DTMF_DOMINANT_RATIO = 4.0f (≈ 6 dB separation within a group)
|
||||
* A real DTMF tone drives exactly one row and one column. If two
|
||||
* frequencies in the same group are within 6 dB of each other, it is
|
||||
* more likely noise or voice than a keypad press.
|
||||
*
|
||||
* DTMF_TWIST_MAX = 8.0f (≈ 9 dB)
|
||||
* ITU-T Q.24 allows up to ±8 dB twist between low/high group. We use
|
||||
* 8× power ratio which corresponds to ≈ 9 dB — slightly relaxed to
|
||||
* accommodate the varied mic responses of vintage telephone handsets.
|
||||
*
|
||||
* DTMF_CONFIRM_FRAMES = 2 (2 × 20 ms = 40 ms minimum tone duration)
|
||||
* ITU-T Q.24 specifies ≥ 40 ms tone duration for valid DTMF.
|
||||
*
|
||||
* DTMF_RELEASE_FRAMES = 1 (≥ 20 ms inter-digit silence required)
|
||||
* Prevents a single sustained keypress from re-triggering.
|
||||
*/
|
||||
|
||||
/* sdkconfig.h must be included before any CONFIG_* test so the preprocessor
|
||||
* has the symbol defined when the #if guard below is evaluated. */
|
||||
#include "sdkconfig.h"
|
||||
|
||||
#if CONFIG_PLIP_DIAL_DTMF
|
||||
|
||||
#include "dtmf.h"
|
||||
#include "audio.h"
|
||||
#include "dialer.h"
|
||||
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "esp_log.h"
|
||||
|
||||
#include <math.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#define TAG "dtmf"
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Detection thresholds (see rationale in file header)
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
/* Minimum Goertzel power (mean-squared × N) for a frequency to be considered
|
||||
* "present". Both the row and column candidate must exceed this. */
|
||||
#define DTMF_ENERGY_THRESH 4000000LL
|
||||
|
||||
/* Minimum ratio of best/second-best power within a group. Below this the
|
||||
* group is ambiguous (noise / voice) and the frame is rejected. */
|
||||
#define DTMF_DOMINANT_RATIO 4.0f
|
||||
|
||||
/* Maximum ratio of low-group / high-group power (and its inverse).
|
||||
* Exceeding this means one group is far stronger than expected for DTMF. */
|
||||
#define DTMF_TWIST_MAX 8.0f
|
||||
|
||||
/* Consecutive frames required before a digit is reported. */
|
||||
#define DTMF_CONFIRM_FRAMES 2
|
||||
|
||||
/* Frames of "no valid tone" required between two reports. */
|
||||
#define DTMF_RELEASE_FRAMES 1
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* DTMF frequency table
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
#define NUM_LOW 4
|
||||
#define NUM_HIGH 4
|
||||
|
||||
static const float LOW_FREQS[NUM_LOW] = { 697.0f, 770.0f, 852.0f, 941.0f };
|
||||
static const float HIGH_FREQS[NUM_HIGH] = { 1209.0f, 1336.0f, 1477.0f, 1633.0f };
|
||||
|
||||
/* DTMF matrix: [low_idx][high_idx] → character.
|
||||
* Column 3 (1633 Hz) maps to ABCD which are unused on standard phones → '\0'. */
|
||||
static const char DTMF_MATRIX[NUM_LOW][NUM_HIGH] = {
|
||||
{ '1', '2', '3', '\0' }, /* 697 Hz row */
|
||||
{ '4', '5', '6', '\0' }, /* 770 Hz row */
|
||||
{ '7', '8', '9', '\0' }, /* 852 Hz row */
|
||||
{ '*', '0', '#', '\0' }, /* 941 Hz row */
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Goertzel coefficient cache (precomputed at first call)
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
#define FS 16000 /* sample rate */
|
||||
#define N_SAMP 320 /* frame size */
|
||||
|
||||
static bool s_coeff_ready = false;
|
||||
static float s_low_coeff[NUM_LOW];
|
||||
static float s_high_coeff[NUM_HIGH];
|
||||
|
||||
static void precompute_coeffs(void)
|
||||
{
|
||||
for (int i = 0; i < NUM_LOW; i++) {
|
||||
float k = (float)N_SAMP * LOW_FREQS[i] / (float)FS;
|
||||
s_low_coeff[i] = 2.0f * cosf(2.0f * (float)M_PI * k / (float)N_SAMP);
|
||||
}
|
||||
for (int i = 0; i < NUM_HIGH; i++) {
|
||||
float k = (float)N_SAMP * HIGH_FREQS[i] / (float)FS;
|
||||
s_high_coeff[i] = 2.0f * cosf(2.0f * (float)M_PI * k / (float)N_SAMP);
|
||||
}
|
||||
s_coeff_ready = true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Goertzel power for a single frequency
|
||||
* Power = Q1² + Q2² − Q1·Q2·coeff (unnormalised, proportional to amplitude²)
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
static float goertzel_power(const int16_t *samples, int n, float coeff)
|
||||
{
|
||||
float q1 = 0.0f, q2 = 0.0f;
|
||||
for (int i = 0; i < n; i++) {
|
||||
float q0 = coeff * q1 - q2 + (float)samples[i];
|
||||
q2 = q1;
|
||||
q1 = q0;
|
||||
}
|
||||
return q1 * q1 + q2 * q2 - q1 * q2 * coeff;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Public API: dtmf_detect_frame
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
char dtmf_detect_frame(const int16_t *mono, int n)
|
||||
{
|
||||
if (n <= 0 || !mono) return '\0';
|
||||
|
||||
if (!s_coeff_ready) precompute_coeffs();
|
||||
|
||||
/* Compute Goertzel power for all 8 frequencies */
|
||||
float low_pow[NUM_LOW], high_pow[NUM_HIGH];
|
||||
for (int i = 0; i < NUM_LOW; i++)
|
||||
low_pow[i] = goertzel_power(mono, n, s_low_coeff[i]);
|
||||
for (int i = 0; i < NUM_HIGH; i++)
|
||||
high_pow[i] = goertzel_power(mono, n, s_high_coeff[i]);
|
||||
|
||||
/* Find strongest in each group */
|
||||
int best_low = 0, best_high = 0;
|
||||
float max_low = low_pow[0], max_high = high_pow[0];
|
||||
for (int i = 1; i < NUM_LOW; i++) {
|
||||
if (low_pow[i] > max_low) { max_low = low_pow[i]; best_low = i; }
|
||||
}
|
||||
for (int i = 1; i < NUM_HIGH; i++) {
|
||||
if (high_pow[i] > max_high) { max_high = high_pow[i]; best_high = i; }
|
||||
}
|
||||
|
||||
/* Guard (a): absolute energy threshold */
|
||||
if ((int64_t)max_low < DTMF_ENERGY_THRESH || (int64_t)max_high < DTMF_ENERGY_THRESH)
|
||||
goto no_tone;
|
||||
|
||||
/* Guard (b): group dominance — find second-best in each group */
|
||||
{
|
||||
float second_low = 0.0f, second_high = 0.0f;
|
||||
for (int i = 0; i < NUM_LOW; i++) {
|
||||
if (i != best_low && low_pow[i] > second_low)
|
||||
second_low = low_pow[i];
|
||||
}
|
||||
for (int i = 0; i < NUM_HIGH; i++) {
|
||||
if (i != best_high && high_pow[i] > second_high)
|
||||
second_high = high_pow[i];
|
||||
}
|
||||
/* If second-best is within DTMF_DOMINANT_RATIO of best, ambiguous */
|
||||
if (second_low > 0.0f && max_low < DTMF_DOMINANT_RATIO * second_low)
|
||||
goto no_tone;
|
||||
if (second_high > 0.0f && max_high < DTMF_DOMINANT_RATIO * second_high)
|
||||
goto no_tone;
|
||||
}
|
||||
|
||||
/* Guard (c): twist — power ratio must be within [1/TWIST_MAX, TWIST_MAX] */
|
||||
{
|
||||
float ratio = max_low / max_high;
|
||||
if (ratio > DTMF_TWIST_MAX || ratio < (1.0f / DTMF_TWIST_MAX))
|
||||
goto no_tone;
|
||||
}
|
||||
|
||||
/* --- Debounce state (static) --- */
|
||||
{
|
||||
static char s_candidate = '\0';
|
||||
static int s_confirm_count = 0;
|
||||
static int s_release_count = 0;
|
||||
static bool s_armed = true; /* true = ready to report */
|
||||
|
||||
char sym = DTMF_MATRIX[best_low][best_high];
|
||||
if (sym == '\0') goto no_tone; /* 1633 Hz column — ignored */
|
||||
|
||||
/* Reset release counter: we have a tone */
|
||||
s_release_count = 0;
|
||||
|
||||
if (!s_armed) {
|
||||
/* Waiting for silence/release before accepting next press */
|
||||
return '\0';
|
||||
}
|
||||
|
||||
if (sym == s_candidate) {
|
||||
s_confirm_count++;
|
||||
} else {
|
||||
s_candidate = sym;
|
||||
s_confirm_count = 1;
|
||||
}
|
||||
|
||||
if (s_confirm_count >= DTMF_CONFIRM_FRAMES) {
|
||||
/* Confirmed — report and disarm until release */
|
||||
s_confirm_count = 0;
|
||||
s_candidate = '\0';
|
||||
s_armed = false;
|
||||
return sym;
|
||||
}
|
||||
return '\0';
|
||||
|
||||
no_tone:
|
||||
/* No valid tone detected: advance release counter */
|
||||
; /* label must precede a statement */
|
||||
s_confirm_count = 0;
|
||||
s_candidate = '\0';
|
||||
if (!s_armed) {
|
||||
s_release_count++;
|
||||
if (s_release_count >= DTMF_RELEASE_FRAMES) {
|
||||
s_armed = true;
|
||||
s_release_count = 0;
|
||||
}
|
||||
}
|
||||
return '\0';
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Background capture task
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
#define DTMF_TASK_STACK 4096
|
||||
#define DTMF_TASK_PRIO 3
|
||||
#define DTMF_FRAME_SIZE 320
|
||||
|
||||
static volatile bool s_armed_flag = false; /* true = task should process frames */
|
||||
static TaskHandle_t s_task_handle = NULL;
|
||||
|
||||
static void dtmf_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
int16_t frame[DTMF_FRAME_SIZE];
|
||||
int64_t rms_sq;
|
||||
|
||||
ESP_LOGI(TAG, "dtmf_task started");
|
||||
|
||||
/* Open the capture stream once and keep it open.
|
||||
* Full-duplex: TX (speaker) stays active; RX is already enabled at boot.
|
||||
* We pass generous max_ms / silence_ms since we never call capture_end
|
||||
* while the call is in progress — dtmf_stop() just clears the armed flag. */
|
||||
if (audio_capture_begin(3600000, 3600000) != 0) {
|
||||
ESP_LOGE(TAG, "dtmf_task: capture_begin failed — task exits");
|
||||
s_task_handle = NULL;
|
||||
vTaskDelete(NULL);
|
||||
return;
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
if (!s_armed_flag) {
|
||||
/* Disarmed: drain frames slowly so the RX FIFO doesn't overflow */
|
||||
audio_capture_read_frame(frame, DTMF_FRAME_SIZE, &rms_sq);
|
||||
vTaskDelay(pdMS_TO_TICKS(20));
|
||||
continue;
|
||||
}
|
||||
|
||||
int got = audio_capture_read_frame(frame, DTMF_FRAME_SIZE, &rms_sq);
|
||||
if (got <= 0) continue;
|
||||
|
||||
char sym = dtmf_detect_frame(frame, got);
|
||||
if (sym == '\0') continue;
|
||||
|
||||
ESP_LOGI(TAG, "DTMF detected: '%c'", sym);
|
||||
if (sym >= '0' && sym <= '9') {
|
||||
dialer_push_digit(sym - '0');
|
||||
}
|
||||
/* '*' and '#' are logged only — no dialer push for now */
|
||||
}
|
||||
}
|
||||
|
||||
void dtmf_start(void)
|
||||
{
|
||||
if (!s_task_handle) {
|
||||
/* Create the task once */
|
||||
BaseType_t ok = xTaskCreatePinnedToCore(
|
||||
dtmf_task, "dtmf", DTMF_TASK_STACK, NULL, DTMF_TASK_PRIO,
|
||||
&s_task_handle, 1);
|
||||
if (ok != pdPASS) {
|
||||
ESP_LOGE(TAG, "dtmf_start: xTaskCreate failed");
|
||||
return;
|
||||
}
|
||||
}
|
||||
s_armed_flag = true;
|
||||
ESP_LOGI(TAG, "dtmf_start: DTMF detection armed");
|
||||
}
|
||||
|
||||
void dtmf_stop(void)
|
||||
{
|
||||
s_armed_flag = false;
|
||||
ESP_LOGI(TAG, "dtmf_stop: DTMF detection disarmed");
|
||||
}
|
||||
|
||||
#endif /* CONFIG_PLIP_DIAL_DTMF */
|
||||
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
/*
|
||||
* dtmf.h — DTMF (touch-tone) detector via Goertzel algorithm.
|
||||
*
|
||||
* API:
|
||||
* dtmf_detect_frame() — pure signal processing, no I/O, testable standalone
|
||||
* dtmf_start() / dtmf_stop() — arm/disarm the background capture task
|
||||
*
|
||||
* The capture task reads 20 ms frames from audio_capture_read_frame() and calls
|
||||
* dialer_push_digit() for confirmed digit presses (digits 0-9 only).
|
||||
*
|
||||
* Guard: all declarations and the task body are compiled only when
|
||||
* CONFIG_PLIP_DIAL_DTMF is set (see Kconfig.projbuild).
|
||||
*/
|
||||
|
||||
#include "sdkconfig.h"
|
||||
|
||||
#if CONFIG_PLIP_DIAL_DTMF
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/*
|
||||
* Analyse one 20 ms frame (320 samples at 16 kHz) for a DTMF tone.
|
||||
*
|
||||
* Returns the detected character ('0'-'9', '*', '#') once per confirmed press,
|
||||
* or '\0' when nothing is detected or debounce is still pending.
|
||||
*
|
||||
* Debounce rules (internal static state):
|
||||
* - A symbol must appear in ≥ DTMF_CONFIRM_FRAMES consecutive frames to be
|
||||
* reported.
|
||||
* - After a report, at least DTMF_RELEASE_FRAMES of "no tone" must be seen
|
||||
* before the same (or another) symbol can be reported again.
|
||||
*/
|
||||
char dtmf_detect_frame(const int16_t *mono, int n);
|
||||
|
||||
/*
|
||||
* Start the DTMF background task (created once; idempotent re-arm).
|
||||
* The task reads microphone frames and pushes confirmed 0-9 digits to the
|
||||
* dialer. Must be called after audio_init().
|
||||
*/
|
||||
void dtmf_start(void);
|
||||
|
||||
/*
|
||||
* Disarm the DTMF task. The task suspends itself; the RX stream continues
|
||||
* for the benefit of the voice capture path (full-duplex architecture).
|
||||
*/
|
||||
void dtmf_stop(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* CONFIG_PLIP_DIAL_DTMF */
|
||||
+46
-11
@@ -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,11 +168,11 @@ 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, 0xBB) != ESP_OK) return ESP_FAIL; /* PGA +24dB L+R */
|
||||
/* ADCCONTROL2 (0x0A): LINSEL/RINSEL = 0x00 → LINPUT1/RINPUT1 (LINE IN header on A1S kit)
|
||||
* 0x00 = LIN1/RIN1 differential (standard LINE IN jack where AG1171 SLIC audio is wired)
|
||||
* 0x50 = LIN2/RIN2 single-ended (onboard mic path — no SLIC signal here) */
|
||||
if (i2c_write_reg(ES8388_ADC_CTL2, 0x00) != ESP_OK) return ESP_FAIL; /* LIN1/RIN1 differential (LINE IN / AG1171 SLIC) */
|
||||
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). */
|
||||
if (i2c_write_reg(ES8388_ADC_CTL2, 0x50) != ESP_OK) return ESP_FAIL; /* LIN2/RIN2 — SLIC handset mic */
|
||||
if (i2c_write_reg(ES8388_ADC_CTL3, 0x02) != ESP_OK) return ESP_FAIL; /* DS filter sel */
|
||||
if (i2c_write_reg(ES8388_ADC_CTL4, 0x0C) != ESP_OK) return ESP_FAIL; /* I2S 16-bit */
|
||||
if (i2c_write_reg(ES8388_ADC_CTL5, 0x02) != ESP_OK) return ESP_FAIL; /* RATIO=256 */
|
||||
@@ -224,22 +241,40 @@ esp_err_t es8388_init(void)
|
||||
es8388_read_reg(ES8388_CHIP_POWER, &chippower);
|
||||
ESP_LOGI(TAG, "ES8388 regs: CTL1=0x%02X ADCPWR=0x%02X ADCINSEL=0x%02X ADCCTL3=0x%02X DACCTL21=0x%02X CHIPPOWER=0x%02X",
|
||||
ctl1, adcpwr, adcinsel, adcctl3, dacctl21, chippower);
|
||||
ESP_LOGI(TAG, "ES8388 init OK — PA enabled, DAC @ 0dB, ADC PGA +24dB, input=LIN1/RIN1 (LINE IN), DACCTL21=0x80");
|
||||
ESP_LOGI(TAG, "ES8388 init OK — PA enabled, DAC @ 0dB, ADC PGA +12dB, input=LIN2/RIN2 (SLIC handset), DACCTL21=0x80");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t es8388_set_volume(uint8_t vol)
|
||||
{
|
||||
/* Map 0..100 to 0x00 (0dB) .. 0x24 (mute); register is attenuation.
|
||||
* Volume registers: DACCONTROL24 (0x2E) = OUT1L, DACCONTROL25 (0x2F) = OUT1R,
|
||||
* DACCONTROL26 (0x30) = OUT2L, DACCONTROL27 (0x31) = OUT2R.
|
||||
/* ES8388 OUTx volume registers are GAIN, not attenuation: 0x00 = -45 dB
|
||||
* (min) .. 0x21 = 0 dB (max); higher value = louder (>0x21 = mute/reserved).
|
||||
* Map 0..100 → 0x00..0x21. (The previous code inverted this, so vol=100
|
||||
* produced 0x00 = quietest — confirmed at the bench.)
|
||||
* DACCONTROL24 (0x2E)=OUT1L, 25 (0x2F)=OUT1R, 26 (0x30)=OUT2L, 27 (0x31)=OUT2R.
|
||||
* DACCONTROL21 (0x2B) is the ADC/DAC LRCK sync register — DO NOT touch here. */
|
||||
uint8_t reg_val = (uint8_t)((100 - (int)vol) * 0x24 / 100);
|
||||
ESP_LOGI(TAG, "set_volume: %d%% -> reg=0x%02X", vol, reg_val);
|
||||
if (vol > 100) vol = 100;
|
||||
uint8_t reg_val = (uint8_t)((int)vol * 0x21 / 100);
|
||||
if (reg_val > 0x21) reg_val = 0x21;
|
||||
ESP_LOGI(TAG, "set_volume: %d%% -> reg=0x%02X (0x21=max,0dB)", vol, reg_val);
|
||||
esp_err_t r = ESP_OK;
|
||||
r |= i2c_write_reg(ES8388_DAC_CTL24, reg_val); /* OUT1 L volume */
|
||||
r |= i2c_write_reg(ES8388_DAC_CTL25, reg_val); /* OUT1 R volume */
|
||||
r |= i2c_write_reg(ES8388_DAC_CTL26, reg_val); /* OUT2 L volume */
|
||||
r |= i2c_write_reg(0x31, reg_val); /* OUT2 R volume (DACCONTROL27) */
|
||||
return r;
|
||||
}
|
||||
|
||||
esp_err_t es8388_set_dac_volume(uint8_t atten)
|
||||
{
|
||||
/* DACCONTROL4 (0x04) = LDACVOL, DACCONTROL5 (0x05) = RDACVOL: DIGITAL DAC
|
||||
* volume, applied BEFORE the analog output stages. 0x00 = 0 dB, each step
|
||||
* = -0.5 dB, up to 0xC0 = -96 dB (mute). Lowering this gives analog
|
||||
* headroom while keeping the output-stage volume (es8388_set_volume) high. */
|
||||
if (atten > 0xC0) atten = 0xC0;
|
||||
ESP_LOGI(TAG, "set_dac_volume: atten=0x%02X (-%.1f dB)", atten, atten * 0.5f);
|
||||
esp_err_t r = i2c_write_reg(ES8388_DAC_CTL4, atten);
|
||||
r |= i2c_write_reg(ES8388_DAC_CTL5, atten);
|
||||
return r;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,12 +29,21 @@ esp_err_t es8388_init(void);
|
||||
* OUT1 (headphone) + OUT2 (speaker) attenuation registers. */
|
||||
esp_err_t es8388_set_volume(uint8_t vol);
|
||||
|
||||
/* Set the DIGITAL DAC volume (DACCONTROL4/5), applied before the analog output
|
||||
* stages. atten: 0 = 0 dB (full), each step -0.5 dB, 0xC0 = mute. Lowering it
|
||||
* gives analog headroom while keeping es8388_set_volume() high. */
|
||||
esp_err_t es8388_set_dac_volume(uint8_t atten);
|
||||
|
||||
/* Mute / unmute DAC output. */
|
||||
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
|
||||
|
||||
+370
-19
@@ -21,12 +21,14 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <sys/stat.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"
|
||||
@@ -37,9 +39,9 @@
|
||||
#include "esp_log.h"
|
||||
#include "esp_netif.h"
|
||||
#include "esp_spiffs.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/event_groups.h"
|
||||
#include "freertos/task.h"
|
||||
#include "nvs.h"
|
||||
#include "nvs_flash.h"
|
||||
@@ -52,7 +54,9 @@
|
||||
|
||||
#define SPIFFS_LABEL "storage"
|
||||
#define SPIFFS_BASE "/spiffs"
|
||||
#define MAX_BODY (256 * 1024)
|
||||
#define MAX_BODY (3 * 1024 * 1024) /* streamed in 2 KB chunks → RAM-safe;
|
||||
raised to fit full-length neural-TTS
|
||||
(Kyutai) gamebook narration WAVs */
|
||||
|
||||
static volatile bool s_connected = false;
|
||||
static httpd_handle_t s_httpd = NULL;
|
||||
@@ -84,10 +88,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;
|
||||
@@ -136,9 +143,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);
|
||||
}
|
||||
|
||||
@@ -197,14 +212,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");
|
||||
@@ -242,6 +282,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
|
||||
@@ -405,6 +483,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
|
||||
@@ -437,9 +547,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) ─────────────────────── */
|
||||
@@ -467,6 +588,192 @@ static esp_err_t handle_debug_slic(httpd_req_t *req)
|
||||
return send_json(req, "200 OK", buf);
|
||||
}
|
||||
|
||||
/* ── GET /debug/hookmon (fast-sample the hook GPIO, log transitions) ─────────
|
||||
* Samples CONFIG_PLIP_HOOK_GPIO every 2 ms for ~6 s and records every level
|
||||
* transition with a timestamp. Catches both slow hook toggles AND fast rotary
|
||||
* pulses (~60-100 ms) that the 0.7 s HTTP poll of /debug/slic cannot see.
|
||||
* The user performs the physical action (lift/hang/dial) during the window. */
|
||||
|
||||
#define HOOKMON_SAMPLE_MS 2
|
||||
#define HOOKMON_WINDOW_MS 6000
|
||||
#define HOOKMON_MAX_EDGES 64
|
||||
|
||||
static esp_err_t handle_debug_hookmon(httpd_req_t *req)
|
||||
{
|
||||
const int gpio = CONFIG_PLIP_HOOK_GPIO;
|
||||
int last = gpio_get_level(gpio);
|
||||
const int initial = last;
|
||||
|
||||
int64_t t0 = esp_timer_get_time();
|
||||
int edges_t[HOOKMON_MAX_EDGES];
|
||||
int edges_l[HOOKMON_MAX_EDGES];
|
||||
int n_edges = 0;
|
||||
int lo = last, hi = last; /* track min/max seen */
|
||||
|
||||
ESP_LOGI(TAG, "hookmon: start, gpio=%d initial=%d (sample %dms / window %dms)",
|
||||
gpio, initial, HOOKMON_SAMPLE_MS, HOOKMON_WINDOW_MS);
|
||||
|
||||
for (;;) {
|
||||
int64_t now = esp_timer_get_time();
|
||||
int dt = (int)((now - t0) / 1000);
|
||||
if (dt >= HOOKMON_WINDOW_MS) break;
|
||||
|
||||
int lvl = gpio_get_level(gpio);
|
||||
if (lvl < lo) lo = lvl;
|
||||
if (lvl > hi) hi = lvl;
|
||||
if (lvl != last) {
|
||||
if (n_edges < HOOKMON_MAX_EDGES) {
|
||||
edges_t[n_edges] = dt;
|
||||
edges_l[n_edges] = lvl;
|
||||
n_edges++;
|
||||
}
|
||||
last = lvl;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(HOOKMON_SAMPLE_MS));
|
||||
}
|
||||
|
||||
/* Build JSON: { gpio, initial, final, lo, hi, edges:[{t,l},...], count } */
|
||||
char buf[1024];
|
||||
int off = 0;
|
||||
off += snprintf(buf + off, sizeof(buf) - off,
|
||||
"{\"gpio\":%d,\"initial\":%d,\"final\":%d,\"lo\":%d,\"hi\":%d,"
|
||||
"\"count\":%d,\"edges\":[",
|
||||
gpio, initial, last, lo, hi, n_edges);
|
||||
for (int i = 0; i < n_edges && off < (int)sizeof(buf) - 32; i++) {
|
||||
off += snprintf(buf + off, sizeof(buf) - off, "%s{\"t\":%d,\"l\":%d}",
|
||||
i ? "," : "", edges_t[i], edges_l[i]);
|
||||
}
|
||||
off += snprintf(buf + off, sizeof(buf) - off, "]}");
|
||||
|
||||
ESP_LOGI(TAG, "hookmon: done, %d edges, lo=%d hi=%d", n_edges, lo, hi);
|
||||
return send_json(req, "200 OK", buf);
|
||||
}
|
||||
|
||||
/* ── GET /debug/dacvol?a=N (set ES8388 DIGITAL DAC volume, live tuning) ─────── */
|
||||
|
||||
static esp_err_t handle_debug_dacvol(httpd_req_t *req)
|
||||
{
|
||||
char query[32] = {0};
|
||||
httpd_req_get_url_query_str(req, query, sizeof(query));
|
||||
char astr[8] = {0};
|
||||
if (httpd_query_key_value(query, "a", astr, sizeof(astr)) != ESP_OK) {
|
||||
return send_json(req, "400 Bad Request", "{\"error\":\"missing a param (atten steps, 0=0dB)\"}");
|
||||
}
|
||||
int a = atoi(astr);
|
||||
if (a < 0) a = 0;
|
||||
if (a > 192) a = 192;
|
||||
esp_err_t r = es8388_set_dac_volume((uint8_t)a);
|
||||
char resp[80];
|
||||
snprintf(resp, sizeof(resp), "{\"ok\":%s,\"atten_steps\":%d,\"db\":-%.1f}",
|
||||
(r == ESP_OK) ? "true" : "false", a, a * 0.5);
|
||||
ESP_LOGI(TAG, "debug/dacvol: atten=%d (-%.1f dB)", a, a * 0.5);
|
||||
return send_json(req, "200 OK", resp);
|
||||
}
|
||||
|
||||
/* ── 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[64] = {0};
|
||||
httpd_req_get_url_query_str(req, query, sizeof(query));
|
||||
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) ── */
|
||||
|
||||
static esp_err_t handle_debug_getfile(httpd_req_t *req)
|
||||
{
|
||||
char query[160] = {0};
|
||||
httpd_req_get_url_query_str(req, query, sizeof(query));
|
||||
char fpath[96] = {0};
|
||||
if (httpd_query_key_value(query, "path", fpath, sizeof(fpath)) != ESP_OK) {
|
||||
return send_json(req, "400 Bad Request", "{\"error\":\"missing path param\"}");
|
||||
}
|
||||
ensure_spiffs();
|
||||
char full[160];
|
||||
if (fpath[0] == '/') snprintf(full, sizeof(full), "%s%s", SPIFFS_BASE, fpath);
|
||||
else snprintf(full, sizeof(full), "%s/%s", SPIFFS_BASE, fpath);
|
||||
|
||||
FILE *f = fopen(full, "rb");
|
||||
if (!f) return send_json(req, "404 Not Found", "{\"error\":\"not found\"}");
|
||||
|
||||
httpd_resp_set_type(req, "application/octet-stream");
|
||||
char chunk[1024];
|
||||
size_t r;
|
||||
while ((r = fread(chunk, 1, sizeof(chunk), f)) > 0) {
|
||||
if (httpd_resp_send_chunk(req, chunk, r) != ESP_OK) { fclose(f); return ESP_FAIL; }
|
||||
}
|
||||
fclose(f);
|
||||
httpd_resp_send_chunk(req, NULL, 0); /* terminate */
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* ── GET /debug/vol?v=N (set ES8388 output volume 0..100, live tuning) ─────── */
|
||||
|
||||
static esp_err_t handle_debug_vol(httpd_req_t *req)
|
||||
{
|
||||
char query[32] = {0};
|
||||
httpd_req_get_url_query_str(req, query, sizeof(query));
|
||||
char vstr[8] = {0};
|
||||
if (httpd_query_key_value(query, "v", vstr, sizeof(vstr)) != ESP_OK) {
|
||||
return send_json(req, "400 Bad Request", "{\"error\":\"missing v param (0..100)\"}");
|
||||
}
|
||||
int v = atoi(vstr);
|
||||
if (v < 0) v = 0;
|
||||
if (v > 100) v = 100;
|
||||
esp_err_t r = es8388_set_volume((uint8_t)v);
|
||||
char resp[64];
|
||||
snprintf(resp, sizeof(resp), "{\"ok\":%s,\"volume\":%d}", (r == ESP_OK) ? "true" : "false", v);
|
||||
ESP_LOGI(TAG, "debug/vol: set volume=%d (%s)", v, (r == ESP_OK) ? "ok" : "err");
|
||||
return send_json(req, "200 OK", resp);
|
||||
}
|
||||
|
||||
/* ── GET /debug/dial?number=NNNN (push digits into the dialer) ───────────── */
|
||||
|
||||
static esp_err_t handle_debug_dial(httpd_req_t *req)
|
||||
@@ -538,7 +845,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);
|
||||
@@ -565,7 +881,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 = 17;
|
||||
hcfg.max_uri_handlers = 18;
|
||||
hcfg.stack_size = 8192;
|
||||
|
||||
esp_err_t ret = httpd_start(&s_httpd, &hcfg);
|
||||
@@ -586,6 +902,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,
|
||||
@@ -602,6 +922,22 @@ esp_err_t net_init(void)
|
||||
.uri = "/debug/dial", .method = HTTP_GET,
|
||||
.handler = handle_debug_dial, .user_ctx = NULL,
|
||||
};
|
||||
static const httpd_uri_t uri_debug_vol = {
|
||||
.uri = "/debug/vol", .method = HTTP_GET,
|
||||
.handler = handle_debug_vol, .user_ctx = NULL,
|
||||
};
|
||||
static const httpd_uri_t uri_debug_getfile = {
|
||||
.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_ring = {
|
||||
.uri = "/debug/ring", .method = HTTP_GET,
|
||||
.handler = handle_debug_ring, .user_ctx = NULL,
|
||||
@@ -614,16 +950,31 @@ esp_err_t net_init(void)
|
||||
.uri = "/debug/slic", .method = HTTP_GET,
|
||||
.handler = handle_debug_slic, .user_ctx = NULL,
|
||||
};
|
||||
static const httpd_uri_t uri_debug_hookmon = {
|
||||
.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_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;
|
||||
|
||||
+93
-15
@@ -34,9 +34,31 @@
|
||||
#define TAG "phone"
|
||||
|
||||
#define DEBOUNCE_MS 30
|
||||
#define HANGUP_THRESHOLD_MS 500 /* open > this → real hangup, not a pulse */
|
||||
#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_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) */
|
||||
/* real rotary pulses are ≥ 40 ms open */
|
||||
#define HANGUP_VERIFY_COUNT 5 /* re-read GPIO this many times before declaring */
|
||||
/* hangup (guards against marginal cradle contact) */
|
||||
#define HANGUP_VERIFY_STEP_MS 20 /* delay between verification reads (ms); */
|
||||
/* total span = COUNT × STEP = 100 ms */
|
||||
|
||||
static volatile bool s_edge_pending = false;
|
||||
static volatile bool s_ringing = false;
|
||||
@@ -114,6 +136,7 @@ static void phone_task(void *arg)
|
||||
/* Read and report initial level so master state machine is in sync. */
|
||||
int last_level = gpio_get_level(hook_gpio);
|
||||
s_offhook = (last_level == HOOK_OFFHOOK_LEVEL);
|
||||
int resync_ms = 0; /* accumulates while raw level stably disagrees with s_offhook */
|
||||
ESP_LOGI(TAG, "phone task ready, hook GPIO=%d level=%d active_%s (%s)",
|
||||
hook_gpio, last_level,
|
||||
(HOOK_OFFHOOK_LEVEL == 1) ? "high" : "low",
|
||||
@@ -163,8 +186,14 @@ static void phone_task(void *arg)
|
||||
last_close_us = esp_timer_get_time();
|
||||
int64_t open_dur_ms = (last_close_us - pulse_open_us) / 1000;
|
||||
|
||||
if (in_pulse && open_dur_ms < HANGUP_THRESHOLD_MS) {
|
||||
/* Short open: count as a rotary pulse */
|
||||
if (in_pulse && open_dur_ms < PULSE_MIN_WIDTH_MS) {
|
||||
/* Glitch filter: open was too brief to be a real pulse.
|
||||
* Ignore — do not count as pulse and do not declare hangup. */
|
||||
in_pulse = false;
|
||||
ESP_LOGD(TAG, "glitch ignored (%"PRId64"ms < %dms)",
|
||||
open_dur_ms, PULSE_MIN_WIDTH_MS);
|
||||
} else if (in_pulse && open_dur_ms < HANGUP_THRESHOLD_MS) {
|
||||
/* Valid rotary pulse duration: count it */
|
||||
pulse_count++;
|
||||
in_pulse = false;
|
||||
ESP_LOGD(TAG, "pulse %d (open %"PRId64"ms)", pulse_count, open_dur_ms);
|
||||
@@ -231,13 +260,37 @@ static void phone_task(void *arg)
|
||||
}
|
||||
|
||||
/* Also detect prolonged open (hangup) even if no more edges arrive.
|
||||
* Active-HIGH: hangup = level stays at HOOK_PULSE_OPEN (LOW) > 500 ms. */
|
||||
* Active-HIGH: hangup = level stays at HOOK_PULSE_OPEN (LOW) > 500 ms.
|
||||
*
|
||||
* Hardening: re-sample the GPIO HANGUP_VERIFY_COUNT times with
|
||||
* HANGUP_VERIFY_STEP_MS gaps. Only declare hangup if every sample
|
||||
* confirms the open state — guards against a marginal cradle contact
|
||||
* that briefly dips below HANGUP_THRESHOLD_MS and then bounces back. */
|
||||
if (s_offhook && in_pulse && pulse_open_us > 0) {
|
||||
int64_t open_ms = (esp_timer_get_time() - pulse_open_us) / 1000;
|
||||
if (open_ms >= HANGUP_THRESHOLD_MS) {
|
||||
int level_now = gpio_get_level(hook_gpio);
|
||||
if (level_now == HOOK_PULSE_OPEN) {
|
||||
ESP_LOGI(TAG, "on-hook (prolonged open %"PRId64"ms) detected", open_ms);
|
||||
/* Multi-sample verification */
|
||||
bool confirmed = true;
|
||||
for (int v = 0; v < HANGUP_VERIFY_COUNT; v++) {
|
||||
vTaskDelay(pdMS_TO_TICKS(HANGUP_VERIFY_STEP_MS));
|
||||
if (gpio_get_level(hook_gpio) != HOOK_PULSE_OPEN) {
|
||||
/* Line recovered during verification — not a real hangup */
|
||||
confirmed = false;
|
||||
ESP_LOGD(TAG, "hangup verify failed at sample %d — bounce", v);
|
||||
/* Treat the level as if the line just closed */
|
||||
last_level = HOOK_PULSE_CLOSED;
|
||||
last_close_us = esp_timer_get_time();
|
||||
/* The open was shorter than HANGUP_THRESHOLD_MS in effect,
|
||||
* but we already passed the threshold — do not count as
|
||||
* a pulse (it's the same ambiguous situation we reject
|
||||
* elsewhere). Simply clear in_pulse. */
|
||||
in_pulse = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (confirmed) {
|
||||
ESP_LOGI(TAG, "on-hook (prolonged open %"PRId64"ms, verified ×%d) detected",
|
||||
open_ms, HANGUP_VERIFY_COUNT);
|
||||
last_level = HOOK_PULSE_OPEN;
|
||||
pulse_count = 0;
|
||||
in_pulse = false;
|
||||
@@ -245,20 +298,45 @@ static void phone_task(void *arg)
|
||||
audio_stop();
|
||||
audio_pa_set(false);
|
||||
report_offhook(false);
|
||||
} else {
|
||||
/* GPIO returned to off-hook level but ISR missed it */
|
||||
last_level = HOOK_PULSE_CLOSED;
|
||||
last_close_us = esp_timer_get_time();
|
||||
if ((esp_timer_get_time() - pulse_open_us) / 1000 < HANGUP_THRESHOLD_MS) {
|
||||
pulse_count++;
|
||||
}
|
||||
in_pulse = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
poll_sleep:
|
||||
#endif
|
||||
/* ── Self-healing resync ──────────────────────────────────────────────
|
||||
* The marginal A1S cradle contact occasionally makes the edge detection
|
||||
* miss/flap a transition, leaving s_offhook stuck (e.g. firmware thinks
|
||||
* on-hook while the handset is physically off-hook → the call never
|
||||
* starts). If the raw level stably disagrees with s_offhook for
|
||||
* RESYNC_STABLE_MS — and we are NOT mid rotary-pulse — trust the
|
||||
* physical level and correct s_offhook, firing the transition. */
|
||||
{
|
||||
bool pulse_active = false;
|
||||
#if CONFIG_PLIP_DIAL_PULSE
|
||||
pulse_active = in_pulse;
|
||||
#endif
|
||||
bool phys_off = (gpio_get_level(hook_gpio) == HOOK_OFFHOOK_LEVEL);
|
||||
if (!pulse_active && phys_off != s_offhook) {
|
||||
resync_ms += 10;
|
||||
/* 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;
|
||||
last_level = phys_off ? HOOK_OFFHOOK_LEVEL : !HOOK_OFFHOOK_LEVEL;
|
||||
audio_pa_set(phys_off);
|
||||
if (!phys_off) audio_stop();
|
||||
report_offhook(phys_off);
|
||||
resync_ms = 0;
|
||||
}
|
||||
} else {
|
||||
resync_ms = 0;
|
||||
}
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
// 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 "esp_random.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_begin_random(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
|
||||
int idx = (int)(esp_random() % (uint32_t)s_lib_n);
|
||||
ESP_LOGI(TAG, "auto-start random story #%d/%d", idx, s_lib_n);
|
||||
load_book(idx); // straight into the story, no 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,48 @@
|
||||
#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);
|
||||
|
||||
// Démarre directement UNE histoire tirée au hasard (sans passer par le menu) —
|
||||
// utilisé quand le téléphone sonne tout seul et qu'on décroche.
|
||||
void plip_gamebook_begin_random(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
|
||||
+59
-18
@@ -20,33 +20,68 @@
|
||||
|
||||
#define TAG "slic"
|
||||
|
||||
/* K50835F SHK is OPEN-COLLECTOR ACTIVE-LOW: the SLIC pulls SHK LOW when the
|
||||
* loop is closed (off-hook) and releases it (pull-up → HIGH) when on-hook.
|
||||
* So off-hook = LOW. (A252 used active-high via an external inverter; the A1S
|
||||
* wires SHK straight to the GPIO, so the raw polarity is active-low.) */
|
||||
#define SLIC_SHK_OFFHOOK_LEVEL 0
|
||||
/* Empirically on THIS A1S+SLIC unit, SHK is ACTIVE-HIGH: off-hook (loop closed)
|
||||
* drives SHK HIGH, on-hook drives it LOW. Confirmed at the bench via
|
||||
* /debug/hookmon: on-hook→0, pickup-during-ring→0→1 transition, off-hook→1.
|
||||
* (The A252 reference was active-low; the polarity is inverted here, so the
|
||||
* raw straight-wired GPIO reads off-hook = HIGH.) */
|
||||
#define SLIC_SHK_OFFHOOK_LEVEL 1
|
||||
|
||||
static volatile bool s_ringing = false;
|
||||
static TaskHandle_t s_ring_task = NULL;
|
||||
|
||||
/* ── FR toggle task (ring drive at ~25 Hz) ────────────────────────────────── */
|
||||
/* France Télécom ring cadence: 1.5 s burst ON, 3.5 s silent pause, repeating.
|
||||
* (Single-ring cadence — distinct from the UK double-ring or US 2 s/4 s.)
|
||||
* The bell is driven during the burst by toggling FR at ~25 Hz with RM HIGH;
|
||||
* during the pause RM and FR are held low so the bell is silent. */
|
||||
#define RING_FR_TOGGLE_MS 20 /* 20 ms half-period → ~25 Hz bell drive */
|
||||
#define RING_BURST_MS 1500 /* FT: 1.5 s of ringing */
|
||||
#define RING_PAUSE_MS 3500 /* FT: 3.5 s of silence */
|
||||
|
||||
/* ── FR toggle + cadence task ─────────────────────────────────────────────── */
|
||||
|
||||
static void slic_ring_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
bool fr_state = false;
|
||||
bool in_burst = true; /* start each ring with a burst */
|
||||
int phase_ms = 0; /* elapsed ms in the current burst/pause phase */
|
||||
|
||||
for (;;) {
|
||||
if (s_ringing) {
|
||||
fr_state = !fr_state;
|
||||
gpio_set_level(PLIP_SLIC_FR, fr_state ? 1 : 0);
|
||||
ESP_LOGV(TAG, "FR=%d", fr_state ? 1 : 0);
|
||||
if (in_burst) {
|
||||
/* Ring burst: RM HIGH, FR toggling at ~25 Hz to swing the bell */
|
||||
gpio_set_level(PLIP_SLIC_RM, 1);
|
||||
fr_state = !fr_state;
|
||||
gpio_set_level(PLIP_SLIC_FR, fr_state ? 1 : 0);
|
||||
phase_ms += RING_FR_TOGGLE_MS;
|
||||
if (phase_ms >= RING_BURST_MS) {
|
||||
/* End of burst → enter silent pause */
|
||||
in_burst = false;
|
||||
phase_ms = 0;
|
||||
fr_state = false;
|
||||
gpio_set_level(PLIP_SLIC_RM, 0);
|
||||
gpio_set_level(PLIP_SLIC_FR, 0);
|
||||
ESP_LOGV(TAG, "ring: burst end -> pause");
|
||||
}
|
||||
} else {
|
||||
/* Silent pause: RM/FR stay low */
|
||||
phase_ms += RING_FR_TOGGLE_MS;
|
||||
if (phase_ms >= RING_PAUSE_MS) {
|
||||
in_burst = true;
|
||||
phase_ms = 0;
|
||||
ESP_LOGV(TAG, "ring: pause end -> burst");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
/* Suspended — keep FR low while idle */
|
||||
/* Idle — keep RM/FR low and reset the cadence for the next ring */
|
||||
gpio_set_level(PLIP_SLIC_RM, 0);
|
||||
gpio_set_level(PLIP_SLIC_FR, 0);
|
||||
fr_state = false;
|
||||
in_burst = true;
|
||||
phase_ms = 0;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(20)); /* 20 ms → ~25 Hz */
|
||||
vTaskDelay(pdMS_TO_TICKS(RING_FR_TOGGLE_MS));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,10 +89,12 @@ static void slic_ring_task(void *arg)
|
||||
|
||||
esp_err_t slic_init(void)
|
||||
{
|
||||
/* RM: Ring Mode output, init LOW */
|
||||
/* RM: Ring Mode output, init LOW.
|
||||
* INPUT_OUTPUT (not plain OUTPUT) so gpio_get_level() reads back the real
|
||||
* pad level — plain OUTPUT disables the input buffer and always reads 0. */
|
||||
gpio_config_t rm_cfg = {
|
||||
.pin_bit_mask = (1ULL << PLIP_SLIC_RM),
|
||||
.mode = GPIO_MODE_OUTPUT,
|
||||
.mode = GPIO_MODE_INPUT_OUTPUT,
|
||||
.pull_up_en = GPIO_PULLUP_DISABLE,
|
||||
.pull_down_en = GPIO_PULLDOWN_DISABLE,
|
||||
.intr_type = GPIO_INTR_DISABLE,
|
||||
@@ -66,10 +103,10 @@ esp_err_t slic_init(void)
|
||||
if (ret != ESP_OK) return ret;
|
||||
gpio_set_level(PLIP_SLIC_RM, 0);
|
||||
|
||||
/* FR: Forward/Reverse output, init LOW */
|
||||
/* FR: Forward/Reverse output, init LOW (INPUT_OUTPUT for readback, see RM). */
|
||||
gpio_config_t fr_cfg = {
|
||||
.pin_bit_mask = (1ULL << PLIP_SLIC_FR),
|
||||
.mode = GPIO_MODE_OUTPUT,
|
||||
.mode = GPIO_MODE_INPUT_OUTPUT,
|
||||
.pull_up_en = GPIO_PULLUP_DISABLE,
|
||||
.pull_down_en = GPIO_PULLDOWN_DISABLE,
|
||||
.intr_type = GPIO_INTR_DISABLE,
|
||||
@@ -92,7 +129,9 @@ esp_err_t slic_init(void)
|
||||
/* PD: Power Down — EXACT A252-proven sequence: open-drain output, HIGH = released
|
||||
* = SLIC active (setPowerDown(false) in Ks0835SlicController). This is the config
|
||||
* the working Arduino slic-phone project uses on the same chip. */
|
||||
ret = gpio_set_direction(PLIP_SLIC_PD, GPIO_MODE_OUTPUT_OD);
|
||||
/* INPUT_OUTPUT_OD (not plain OUTPUT_OD) so gpio_get_level() reads the real
|
||||
* pad level — OUTPUT_OD also disables the input buffer and would read 0. */
|
||||
ret = gpio_set_direction(PLIP_SLIC_PD, GPIO_MODE_INPUT_OUTPUT_OD);
|
||||
if (ret != ESP_OK) return ret;
|
||||
ret = gpio_set_level(PLIP_SLIC_PD, 1); /* open-drain released HIGH = active (A252-proven) */
|
||||
if (ret != ESP_OK) return ret;
|
||||
@@ -123,8 +162,10 @@ bool slic_is_offhook(void)
|
||||
void slic_ring_start(void)
|
||||
{
|
||||
if (s_ringing) return;
|
||||
ESP_LOGI(TAG, "ring start: RM=HIGH, FR toggling at 25 Hz");
|
||||
gpio_set_level(PLIP_SLIC_RM, 1);
|
||||
ESP_LOGI(TAG, "ring start: France Télécom cadence %d ms ON / %d ms OFF",
|
||||
RING_BURST_MS, RING_PAUSE_MS);
|
||||
/* The cadence task drives RM/FR; it starts on a burst (in_burst reset in
|
||||
* the idle branch). Just arm it here. */
|
||||
s_ringing = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
/*
|
||||
* turn_client.c — NPC gateway client for /v1/voice/turn.
|
||||
* turn_client.c — NPC gateway client for /v1/voice/turn and /v1/voice/reply.
|
||||
*
|
||||
* Uses esp_http_client open/write/fetch/read streaming so the binary WAV
|
||||
* body can be written directly to SPIFFS without a large heap buffer.
|
||||
*
|
||||
* Pattern mirrors hook_client.c but synchronous (called from conv_task).
|
||||
* Timeout is generous (30 s) because TTS synthesis adds latency.
|
||||
*
|
||||
* Stage 3 adds turn_client_reply() which POSTs captured mic audio as
|
||||
* multipart/form-data to /v1/voice/reply and streams the NPC response WAV.
|
||||
*/
|
||||
|
||||
#include "turn_client.h"
|
||||
@@ -26,8 +29,17 @@
|
||||
/* Read chunks when streaming the response body. */
|
||||
#define CHUNK_SIZE 1024
|
||||
|
||||
/* Write chunks when uploading the captured WAV body (4 KB). */
|
||||
#define UPLOAD_CHUNK 4096
|
||||
|
||||
/* Timeout for the full TTS round-trip (connect + synthesis + transfer). */
|
||||
#define TIMEOUT_MS 30000
|
||||
#define TIMEOUT_MS 90000 /* reply TTS (Kyutai MLX ~0.3x realtime) can take
|
||||
* tens of seconds; was 30s → reply POST timed out
|
||||
* (HTTP -1). 90s covers worst-case generation. */
|
||||
|
||||
/* Multipart boundary (must not appear in the WAV payload — 16kHz PCM is binary
|
||||
* so any fixed ASCII boundary is safe). */
|
||||
#define BOUNDARY "----ZacusPlipBoundary7MA4YWxkTrZu0gW"
|
||||
|
||||
bool turn_client_greeting(const char *session_id,
|
||||
const char *number,
|
||||
@@ -129,3 +141,200 @@ bool turn_client_greeting(const char *session_id,
|
||||
ESP_LOGI(TAG, "greeting WAV %d bytes written to %s", total, out_path);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ── turn_client_reply ─────────────────────────────────────────────────────
|
||||
*
|
||||
* POST multipart/form-data to /v1/voice/reply with the captured mic WAV.
|
||||
*
|
||||
* Multipart layout (each part is CRLF-delimited per RFC 2046):
|
||||
*
|
||||
* --<boundary>\r\n
|
||||
* Content-Disposition: form-data; name="session_id"\r\n\r\n
|
||||
* <session_id>\r\n
|
||||
* --<boundary>\r\n
|
||||
* Content-Disposition: form-data; name="number"\r\n\r\n
|
||||
* <number>\r\n
|
||||
* --<boundary>\r\n
|
||||
* Content-Disposition: form-data; name="audio"; filename="rec.wav"\r\n
|
||||
* Content-Type: audio/wav\r\n\r\n
|
||||
* <wav bytes>
|
||||
* \r\n--<boundary>--\r\n
|
||||
*
|
||||
* Content-Length = len(preamble) + wav_len + len(epilogue)
|
||||
* (known exactly before writing — allows non-chunked POST).
|
||||
*/
|
||||
esp_err_t turn_client_reply(const char *session_id,
|
||||
const char *number,
|
||||
const uint8_t *wav,
|
||||
size_t wav_len,
|
||||
const char *out_path)
|
||||
{
|
||||
if (!session_id || !number || !wav || wav_len == 0 || !out_path) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
/* --- Build URL -------------------------------------------------------- */
|
||||
char url[256];
|
||||
snprintf(url, sizeof(url), "%s/v1/voice/reply",
|
||||
CONFIG_PLIP_GATEWAY_URL);
|
||||
|
||||
/* --- Build multipart preamble ---------------------------------------- */
|
||||
/* preamble = 3 parts before the raw wav bytes:
|
||||
* part 1 — session_id (text field)
|
||||
* part 2 — number (text field)
|
||||
* part 3 — audio file header (up to but NOT including the file body)
|
||||
*/
|
||||
char preamble[512];
|
||||
int preamble_len = snprintf(preamble, sizeof(preamble),
|
||||
"--%s\r\n"
|
||||
"Content-Disposition: form-data; name=\"session_id\"\r\n\r\n"
|
||||
"%s\r\n"
|
||||
"--%s\r\n"
|
||||
"Content-Disposition: form-data; name=\"number\"\r\n\r\n"
|
||||
"%s\r\n"
|
||||
"--%s\r\n"
|
||||
"Content-Disposition: form-data; name=\"audio\"; filename=\"rec.wav\"\r\n"
|
||||
"Content-Type: audio/wav\r\n\r\n",
|
||||
BOUNDARY,
|
||||
session_id,
|
||||
BOUNDARY,
|
||||
number,
|
||||
BOUNDARY);
|
||||
|
||||
if (preamble_len <= 0 || preamble_len >= (int)sizeof(preamble)) {
|
||||
ESP_LOGE(TAG, "preamble buffer overflow (len=%d)", preamble_len);
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
|
||||
/* --- Build multipart epilogue ---------------------------------------- */
|
||||
/* epilogue = CRLF after the wav data + closing boundary */
|
||||
char epilogue[64];
|
||||
int epilogue_len = snprintf(epilogue, sizeof(epilogue),
|
||||
"\r\n--%s--\r\n",
|
||||
BOUNDARY);
|
||||
|
||||
if (epilogue_len <= 0 || epilogue_len >= (int)sizeof(epilogue)) {
|
||||
ESP_LOGE(TAG, "epilogue buffer overflow");
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
|
||||
int content_length = preamble_len + (int)wav_len + epilogue_len;
|
||||
|
||||
ESP_LOGI(TAG, "reply POST %s preamble=%d wav=%zu epilogue=%d total=%d",
|
||||
url, preamble_len, wav_len, epilogue_len, content_length);
|
||||
|
||||
/* --- Configure client ------------------------------------------------- */
|
||||
esp_http_client_config_t cfg = {
|
||||
.url = url,
|
||||
.method = HTTP_METHOD_POST,
|
||||
.timeout_ms = TIMEOUT_MS,
|
||||
};
|
||||
esp_http_client_handle_t client = esp_http_client_init(&cfg);
|
||||
if (!client) {
|
||||
ESP_LOGE(TAG, "esp_http_client_init failed");
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
/* Content-Type with boundary */
|
||||
char ct_header[128];
|
||||
snprintf(ct_header, sizeof(ct_header),
|
||||
"multipart/form-data; boundary=%s", BOUNDARY);
|
||||
esp_http_client_set_header(client, "Content-Type", ct_header);
|
||||
|
||||
/* Authorization header — skip if token is empty */
|
||||
const char *token = CONFIG_PLIP_GATEWAY_TOKEN;
|
||||
if (token && token[0] != '\0') {
|
||||
char auth[128];
|
||||
snprintf(auth, sizeof(auth), "Bearer %s", token);
|
||||
esp_http_client_set_header(client, "Authorization", auth);
|
||||
}
|
||||
|
||||
/* --- Open connection and stream body ---------------------------------- */
|
||||
esp_err_t err = esp_http_client_open(client, content_length);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "open %s failed: %s", url, esp_err_to_name(err));
|
||||
esp_http_client_cleanup(client);
|
||||
return err;
|
||||
}
|
||||
|
||||
/* Write preamble */
|
||||
int written = esp_http_client_write(client, preamble, preamble_len);
|
||||
if (written < 0) {
|
||||
ESP_LOGW(TAG, "write preamble failed (ret=%d)", written);
|
||||
esp_http_client_close(client);
|
||||
esp_http_client_cleanup(client);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
/* Write WAV data in 4 KB chunks */
|
||||
size_t remaining = wav_len;
|
||||
const uint8_t *ptr = wav;
|
||||
while (remaining > 0) {
|
||||
size_t to_send = (remaining < UPLOAD_CHUNK) ? remaining : UPLOAD_CHUNK;
|
||||
int wr = esp_http_client_write(client, (const char *)ptr, (int)to_send);
|
||||
if (wr < 0) {
|
||||
ESP_LOGW(TAG, "write wav chunk failed (ret=%d)", wr);
|
||||
esp_http_client_close(client);
|
||||
esp_http_client_cleanup(client);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
ptr += (size_t)wr;
|
||||
remaining -= (size_t)wr;
|
||||
}
|
||||
|
||||
/* Write epilogue */
|
||||
written = esp_http_client_write(client, epilogue, epilogue_len);
|
||||
if (written < 0) {
|
||||
ESP_LOGW(TAG, "write epilogue failed (ret=%d)", written);
|
||||
esp_http_client_close(client);
|
||||
esp_http_client_cleanup(client);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
/* --- Fetch response headers ------------------------------------------ */
|
||||
int content_len = esp_http_client_fetch_headers(client);
|
||||
int status_code = esp_http_client_get_status_code(client);
|
||||
|
||||
ESP_LOGI(TAG, "reply HTTP %d content_length=%d", status_code, content_len);
|
||||
|
||||
if (status_code != 200) {
|
||||
ESP_LOGW(TAG, "gateway returned HTTP %d for reply", status_code);
|
||||
esp_http_client_close(client);
|
||||
esp_http_client_cleanup(client);
|
||||
return ESP_ERR_INVALID_RESPONSE;
|
||||
}
|
||||
|
||||
/* --- Stream binary WAV response into SPIFFS file --------------------- */
|
||||
FILE *fp = fopen(out_path, "wb");
|
||||
if (!fp) {
|
||||
ESP_LOGE(TAG, "fopen(%s, wb) failed", out_path);
|
||||
esp_http_client_close(client);
|
||||
esp_http_client_cleanup(client);
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
static uint8_t s_reply_chunk[CHUNK_SIZE]; /* static: avoids stack pressure */
|
||||
int total = 0;
|
||||
int rd;
|
||||
while ((rd = esp_http_client_read(client, (char *)s_reply_chunk,
|
||||
sizeof(s_reply_chunk))) > 0) {
|
||||
size_t fw = fwrite(s_reply_chunk, 1, (size_t)rd, fp);
|
||||
if ((int)fw != rd) {
|
||||
ESP_LOGW(TAG, "fwrite short: wrote %d of %d bytes", (int)fw, rd);
|
||||
break;
|
||||
}
|
||||
total += rd;
|
||||
}
|
||||
|
||||
fclose(fp);
|
||||
esp_http_client_close(client);
|
||||
esp_http_client_cleanup(client);
|
||||
|
||||
if (total <= MIN_WAV_BYTES) {
|
||||
ESP_LOGW(TAG, "reply WAV too short (%d bytes) — ignoring", total);
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "reply WAV %d bytes written to %s", total, out_path);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
* turn_client.h — POST /v1/voice/turn to the NPC gateway and retrieve a WAV response.
|
||||
*
|
||||
* Stage 2: greeting fetch (kind="greeting").
|
||||
* Stage 3 will add kind="listen" / "speak".
|
||||
* Stage 3: reply fetch via /v1/voice/reply (multipart/form-data with captured WAV).
|
||||
*/
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include "esp_err.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
@@ -30,6 +33,34 @@ bool turn_client_greeting(const char *session_id,
|
||||
const char *number,
|
||||
const char *out_path);
|
||||
|
||||
/*
|
||||
* turn_client_reply — POST /v1/voice/reply as multipart/form-data.
|
||||
*
|
||||
* Endpoint: CONFIG_PLIP_GATEWAY_URL/v1/voice/reply
|
||||
* Method: POST multipart/form-data
|
||||
* Fields: session_id (text), number (text), audio (file, "rec.wav", audio/wav)
|
||||
* Bearer: CONFIG_PLIP_GATEWAY_TOKEN (skipped if empty)
|
||||
*
|
||||
* The function builds the multipart body in three segments:
|
||||
* preamble = boundary + session_id part + boundary + number part +
|
||||
* boundary + audio file header
|
||||
* wav data = wav bytes (wav_len bytes, sent in 4 KB chunks)
|
||||
* epilogue = CRLF + closing boundary
|
||||
*
|
||||
* Content-Length = len(preamble) + wav_len + len(epilogue)
|
||||
* The response body (WAV 16 kHz) is streamed into out_path.
|
||||
*
|
||||
* Response headers X-Zacus-Heard and X-Zacus-Said are logged at INFO level.
|
||||
*
|
||||
* Returns ESP_OK if HTTP 200 and a valid WAV (> 44 bytes) was written.
|
||||
* On any failure: logs a warning and returns an ESP error code.
|
||||
*/
|
||||
esp_err_t turn_client_reply(const char *session_id,
|
||||
const char *number,
|
||||
const uint8_t *wav,
|
||||
size_t wav_len,
|
||||
const char *out_path);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -37,7 +37,7 @@ CONFIG_FREERTOS_IDLE_TASK_STACKSIZE=2048
|
||||
|
||||
# SLIC hook GPIO — SHK on GPIO23 (A1S KEY4, active-HIGH: HIGH = off-hook)
|
||||
CONFIG_PLIP_HOOK_GPIO=23
|
||||
CONFIG_PLIP_HOOK_ACTIVE_HIGH=n
|
||||
CONFIG_PLIP_HOOK_ACTIVE_HIGH=y
|
||||
|
||||
# WiFi credentials — set via `idf.py menuconfig` (PLIP Voice Configuration)
|
||||
# or override locally in sdkconfig (NOT committed).
|
||||
@@ -52,3 +52,10 @@ CONFIG_PLIP_HOOK_ACTIVE_HIGH=n
|
||||
# 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
|
||||
|
||||
# Speaker at max (earpiece)
|
||||
CONFIG_PLIP_SPEAKER_VOLUME=100
|
||||
|
||||
Reference in New Issue
Block a user