Compare commits

...

5 Commits

Author SHA1 Message Date
clement ace740a629 fix(npc): wire scene notify into apply_step
CI / platformio (push) Failing after 6m10s
CI / platformio (pull_request) Failing after 17m59s
notify_gateway_scene() + hints puzzle_start lived only in
npc_engine_set_step(), which had NO callers — the runtime->gateway
sync was dead code, so scripted incoming calls would never fire in a
real game. POST /game/step goes through game_endpoint_apply_step(),
which now resolves the step's scene_id (new field on scene_binding,
parsed straight from the IR) and calls the new
npc_engine_set_scene_by_id(). Hardware-validated: STEP_WARNING ->
scene 3 -> gateway notify -> auto-ring Professeur Zacus on the PLIP.
2026-06-18 15:52:03 +02:00
clement 4ac3981845 fix(npc): gateway scene notify non-blocking
CI / platformio (pull_request) Failing after 4m3s
CI / platformio (push) Failing after 14m44s
The scene->gateway POST ran synchronously (2 s timeout) on the scene
transition, so an offline gateway lagged EVERY scene change by 2 s.
Fire it on a detached one-shot task instead, so the game's scene
transitions never wait on the network. Frees the heap scene copy and
self-deletes. Builds clean.
2026-06-18 15:17:31 +02:00
clement 653a299ea4 feat(npc): push scene changes to voice gateway
CI / platformio (pull_request) Failing after 4m58s
CI / platformio (push) Failing after 12m35s
On each scene change (where it already signals /hints/puzzle_start), the
npc_engine now also POSTs the active SCENE_* to the voice gateway
/game/step?scene=… (best-effort, esp_http_client, Bearer token). The
phone NPCs then disguise THIS scene's hint automatically during a game.
Gateway URL/token hardcoded in main.c like the hints URL (-> NVS later).
2026-06-18 14:04:07 +02:00
clement 54022ed6cc fix(plip): VAD calib for quiet voice + ring stop
CI / platformio (pull_request) Failing after 9m38s
CI / platformio (push) Failing after 13m49s
Recalibrate the capture VAD to the quiet handset voice: onset 1.4%->0.7%
FS (was never triggering -> 'no sustained voice'), silence 0.6%->0.34%
FS and end-of-speech window 600->900 ms so a whole sentence is held
instead of cut mid-phrase. Also force slic_ring_stop() on incoming
pickup so the physical bell always stops when answered (phone.c/slic.c
s_ringing could desync, leaving the bell ringing through the call).
2026-06-17 23:47:08 +02:00
clement cfe429d885 fix(plip): debounce hook hangup against flicker
CI / platformio (pull_request) Failing after 5m23s
CI / platformio (push) Failing after 12m32s
The marginal A1S cradle contact flickers open mid-call; treating a
brief open as a hangup dropped the live conversation. Raise the
prolonged-open hangup threshold to 2.5 s and make the resync
asymmetric: a PICKUP is confirmed fast (600 ms, calls answer promptly)
while a HANGUP needs the line to stay open 2.5 s. Brief flickers no
longer end the call; a real hangup still fires ~2.5 s later.
2026-06-17 19:47:55 +02:00
11 changed files with 160 additions and 28 deletions
@@ -22,4 +22,5 @@ idf_component_register(
p7_coffre
p5_morse
p6_nfc
npc_engine
)
@@ -27,6 +27,7 @@
#include "nvs_flash.h"
#include "hints_client.h"
#include "npc_engine.h"
#include "scenario_mesh.h"
#include "puzzle_binding.h"
#include "local_puzzles.h"
@@ -807,6 +808,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) {
@@ -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;
}
@@ -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
+9
View File
@@ -63,6 +63,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
@@ -530,6 +536,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
+8 -13
View File
@@ -599,19 +599,14 @@ 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). Tuned for the SLIC handset mic at +24 dB PGA:
* speech peaks ~5 % FS / RMS ~1-2 %, noise floor ~0.6 %. Onset must sit
* between (catch quiet speech), silence ABOVE the noise floor (so the turn
* ends when the caller stops instead of running to max_ms). */
/* Measured on the SLIC handset (DC-blocked): speech body sits ~1 % FS RMS
* with peaks ~18 %, noise floor ~0.3 %. The previous silence threshold
* (1.2 %) was ABOVE the speech body, so the recorder classified the
* caller's own voice as silence and cut the turn after ~0.6 s → empty STT.
* Onset must clear noise (3-frame sustained confirm also guards it); silence
* must sit between the noise floor and the speech body so the turn ends only
* on a real pause. */
const int32_t vad_onset_rms_sq = 450 * 450; /* ~1.4% FS² — speech onset above noise */
const int32_t vad_silence_rms_sq = 200 * 200; /* ~0.6% FS² — below speech body, above noise floor */
/* 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 */
int silent_frames = 0;
int total_frames = 0;
+7 -1
View File
@@ -47,7 +47,9 @@
#define CAPTURE_MAX_IRAM (128 * 1024)
#define CAPTURE_MAX_MS_PSRAM 8000
#define CAPTURE_MAX_MS_IRAM 4000
#define CAPTURE_SILENCE_MS 600 /* end-of-speech VAD: shorter = snappier reply (was 800) */
#define CAPTURE_SILENCE_MS 900 /* end-of-speech: needs to ride through brief
* mid-sentence pauses so a whole phrase is
* captured (quiet handset voice). 900 ms. */
#define REPLY_POLL_MS 200 /* interval for checking hook during playback */
#define REPLY_PLAYBACK_EXTRA_MS 500 /* safety margin added to computed WAV duration */
#define BETWEEN_TURNS_MS 300 /* short pause between capture rounds */
@@ -124,6 +126,10 @@ 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;
+20 -13
View File
@@ -34,20 +34,23 @@
#define TAG "phone"
#define DEBOUNCE_MS 30
#define HANGUP_THRESHOLD_MS 1500 /* open > this → real hangup, not a pulse.
* Raised from 500 ms: the A1S cradle contact
* is marginal and produces ~500 ms spurious
* opens that were resetting the call before
* the NPC greeting. A real hangup holds the
* line open indefinitely, so it still fires
* (~1.5 s later). Rotary pulses (~60 ms) and
* closed inter-digit gaps are unaffected. */
#define HANGUP_THRESHOLD_MS 2500 /* open > this → real hangup, not a pulse/flicker.
* HOOK DEBOUNCE: the A1S cradle contact is
* marginal and flickers open for up to ~2 s
* mid-call; treating those as a hangup dropped
* the live conversation. Requiring the line to
* stay open ≥ 2.5 s before hanging up rides
* through the flickers. A real hangup holds the
* line open indefinitely so it still fires
* (~2.5 s later). Rotary pulses (~60 ms) and a
* closed line are unaffected. */
#define TASK_STACK 4096
#define TASK_PRIO 5
#define RESYNC_STABLE_MS 600 /* if the raw hook level stably disagrees with
* s_offhook for this long (no pulse), the edge
* detection missed/flapped a transition →
* self-correct s_offhook to the physical level */
#define RESYNC_PICKUP_MS 600 /* off-hook (pickup) self-correct: fast, so an
* incoming call answers promptly. */
#define RESYNC_HANGUP_MS HANGUP_THRESHOLD_MS /* on-hook (hangup) self-correct:
* same long debounce as the prolonged-open path
* so a flickering contact never drops the call. */
/* Rotary pulse hardening (CONFIG_PLIP_DIAL_PULSE) */
#define PULSE_MIN_WIDTH_MS 20 /* ignore opens shorter than this (glitch filter) */
@@ -316,7 +319,11 @@ poll_sleep:
bool phys_off = (gpio_get_level(hook_gpio) == HOOK_OFFHOOK_LEVEL);
if (!pulse_active && phys_off != s_offhook) {
resync_ms += 10;
if (resync_ms >= RESYNC_STABLE_MS) {
/* Hook debounce: a PICKUP (→off-hook) is confirmed fast so calls
* answer promptly; a HANGUP (→on-hook) needs the long debounce so
* a flickering cradle contact can't drop a live call. */
int need = phys_off ? RESYNC_PICKUP_MS : RESYNC_HANGUP_MS;
if (resync_ms >= need) {
ESP_LOGW(TAG, "hook resync: physical=%s but s_offhook=%d — correcting",
phys_off ? "off-hook" : "on-hook", (int)s_offhook);
s_offhook = phys_off;