Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da7b6ace9c | |||
| be369fa260 | |||
| ace740a629 | |||
| 4ac3981845 | |||
| 653a299ea4 | |||
| 54022ed6cc | |||
| cfe429d885 | |||
| 82759ee536 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+92
-39
@@ -137,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)
|
||||
@@ -599,66 +605,113 @@ int audio_capture_wav(uint8_t *out, size_t out_max, int max_ms, int silence_ms)
|
||||
const int max_frames = (max_ms / 20);
|
||||
const int silence_frames = (silence_ms / 20);
|
||||
|
||||
/* VAD thresholds (raw S16). 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). */
|
||||
const int32_t vad_onset_rms_sq = 550 * 550; /* ~1.7% FS² — close-talk speech above room ambient */
|
||||
const int32_t vad_silence_rms_sq = 400 * 400; /* ~1.2% FS² — above room ambient so the turn ends */
|
||||
/* Calibrated to the QUIET handset voice (DC-blocked: body 0.16-0.8 % FS RMS,
|
||||
* loud syllables ~2-4 %, noise floor ~0.3 %). The old onset (1.4 %) NEVER
|
||||
* triggered → "no sustained voice"; the old silence (0.6 %) sat ABOVE the
|
||||
* body → cut mid-sentence. Now: onset catches a real syllable just above the
|
||||
* floor (3-frame confirm guards clicks); silence sits just above the floor so
|
||||
* the turn ends only on a true pause, holding the whole sentence. */
|
||||
const int32_t vad_onset_rms_sq = 230 * 230; /* ~0.70% FS² — quiet-voice onset */
|
||||
const int32_t vad_silence_rms_sq = 110 * 110; /* ~0.34% FS² — just above noise floor */
|
||||
|
||||
bool voice_started = false;
|
||||
int silent_frames = 0;
|
||||
int total_frames = 0;
|
||||
|
||||
/* One-pole DC blocker (high-pass ~80 Hz) applied to the captured mono.
|
||||
* The SLIC/handset path carries a huge DC/sub-audio offset (~80 % FS,
|
||||
* measured). Left in, it (a) swamps the faint voice and (b) keeps the VAD
|
||||
* RMS permanently above the silence threshold, so the capture never ends
|
||||
* and always runs the full max_ms — and the gateway then sees only a DC
|
||||
* transient, transcribing empty. Removing it lets the VAD track the real
|
||||
* speech envelope (onset/silence work) and hands the gateway a clean,
|
||||
* voice-dominated signal. Telephone band (300-3400 Hz) is untouched.
|
||||
* y[n] = x[n] - x[n-1] + R*y[n-1]; R=0.97 → cutoff ~80 Hz at 16 kHz. */
|
||||
float dc_x1 = 0.0f, dc_y1 = 0.0f;
|
||||
const float dc_R = 0.97f;
|
||||
|
||||
ESP_LOGI(TAG, "capture: RX enabled, max_ms=%d silence_ms=%d", max_ms, silence_ms);
|
||||
|
||||
for (int f = 0; f < max_frames && pcm_written + frame_out_bytes <= pcm_max; f++) {
|
||||
/* ── Phase A: wait for SUSTAINED voice before committing to a capture ─────
|
||||
* Only start recording when the caller actually speaks. We require the
|
||||
* DC-blocked RMS to stay above the onset threshold for ONSET_CONFIRM
|
||||
* consecutive frames (~60 ms) — a single loud frame is rejected as a
|
||||
* transient (e.g. the click when the PA mutes just before capture). Audio
|
||||
* is discarded during this phase. If no sustained voice arrives within
|
||||
* max_ms we return an empty WAV so the caller posts nothing (the NPC must
|
||||
* not "reply" to silence). The listen loop simply calls us again. */
|
||||
const int ONSET_CONFIRM = 3;
|
||||
int onset_run = 0;
|
||||
bool got_onset = false;
|
||||
for (int f = 0; f < max_frames; f++) {
|
||||
size_t bytes_read = 0;
|
||||
ret = i2s_channel_read(s_mic_handle, rx_buf, frame_in_bytes,
|
||||
&bytes_read, pdMS_TO_TICKS(100));
|
||||
if (ret != ESP_OK || bytes_read == 0) {
|
||||
ESP_LOGW(TAG, "capture: read error f=%d: %s", f, esp_err_to_name(ret));
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Downmix stereo → mono, compute RMS². */
|
||||
int16_t *out_frame = (int16_t *)(pcm_out + pcm_written);
|
||||
int64_t rms_sq = 0;
|
||||
int n_stereo = (int)(bytes_read / (2 * sizeof(int16_t)));
|
||||
if (ret != ESP_OK || bytes_read == 0) continue;
|
||||
int64_t rms_sq = 0;
|
||||
int n_stereo = (int)(bytes_read / (2 * sizeof(int16_t)));
|
||||
for (int i = 0; i < n_stereo; i++) {
|
||||
int32_t l = rx_buf[i * 2];
|
||||
int32_t r = rx_buf[i * 2 + 1];
|
||||
int16_t mono = (int16_t)((l + r) / 2);
|
||||
out_frame[i] = mono;
|
||||
rms_sq += (int64_t)mono * mono;
|
||||
float xf = (float)((rx_buf[i * 2] + rx_buf[i * 2 + 1]) / 2);
|
||||
float yf = xf - dc_x1 + dc_R * dc_y1; /* DC-blocking high-pass */
|
||||
dc_x1 = xf; dc_y1 = yf;
|
||||
int32_t s = (int32_t)yf;
|
||||
rms_sq += (int64_t)s * s;
|
||||
}
|
||||
rms_sq /= (n_stereo > 0 ? n_stereo : 1);
|
||||
|
||||
/* VAD logic. */
|
||||
if (!voice_started) {
|
||||
if (rms_sq >= vad_onset_rms_sq) {
|
||||
voice_started = true;
|
||||
silent_frames = 0;
|
||||
ESP_LOGI(TAG, "capture: voice onset at frame %d (rms²=%"PRId64")", f, rms_sq);
|
||||
if (rms_sq >= vad_onset_rms_sq) {
|
||||
if (++onset_run >= ONSET_CONFIRM) {
|
||||
got_onset = true;
|
||||
ESP_LOGI(TAG, "capture: sustained voice onset at frame %d (rms²=%"PRId64")",
|
||||
f, rms_sq);
|
||||
break;
|
||||
}
|
||||
/* Before voice onset: still accumulate (to avoid clipping onset).
|
||||
* But don't count towards silence timeout yet. */
|
||||
} else {
|
||||
onset_run = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (got_onset) {
|
||||
/* ── Phase B: record the utterance until silence_ms of silence ──────── */
|
||||
for (int f = 0; f < max_frames && pcm_written + frame_out_bytes <= pcm_max; f++) {
|
||||
size_t bytes_read = 0;
|
||||
ret = i2s_channel_read(s_mic_handle, rx_buf, frame_in_bytes,
|
||||
&bytes_read, pdMS_TO_TICKS(100));
|
||||
if (ret != ESP_OK || bytes_read == 0) {
|
||||
ESP_LOGW(TAG, "capture: read error f=%d: %s", f, esp_err_to_name(ret));
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Downmix stereo → mono (DC-blocked), compute RMS². */
|
||||
int16_t *out_frame = (int16_t *)(pcm_out + pcm_written);
|
||||
int64_t rms_sq = 0;
|
||||
int n_stereo = (int)(bytes_read / (2 * sizeof(int16_t)));
|
||||
for (int i = 0; i < n_stereo; i++) {
|
||||
float xf = (float)((rx_buf[i * 2] + rx_buf[i * 2 + 1]) / 2);
|
||||
float yf = xf - dc_x1 + dc_R * dc_y1;
|
||||
dc_x1 = xf; dc_y1 = yf;
|
||||
if (yf > 32767.0f) yf = 32767.0f;
|
||||
else if (yf < -32768.0f) yf = -32768.0f;
|
||||
int16_t mono = (int16_t)yf;
|
||||
out_frame[i] = mono;
|
||||
rms_sq += (int64_t)mono * mono;
|
||||
}
|
||||
rms_sq /= (n_stereo > 0 ? n_stereo : 1);
|
||||
|
||||
pcm_written += (size_t)n_stereo * sizeof(int16_t);
|
||||
total_frames = f + 1;
|
||||
|
||||
if (rms_sq < vad_silence_rms_sq) {
|
||||
silent_frames++;
|
||||
if (silent_frames >= silence_frames) {
|
||||
total_frames = f + 1;
|
||||
ESP_LOGI(TAG, "capture: VAD end (silence %d frames at f=%d)", silent_frames, f);
|
||||
pcm_written += (size_t)n_stereo * sizeof(int16_t);
|
||||
if (++silent_frames >= silence_frames) {
|
||||
ESP_LOGI(TAG, "capture: VAD end (silence %d frames at f=%d)",
|
||||
silent_frames, f);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
silent_frames = 0;
|
||||
}
|
||||
}
|
||||
|
||||
pcm_written += (size_t)n_stereo * sizeof(int16_t);
|
||||
total_frames = f + 1;
|
||||
} else {
|
||||
ESP_LOGI(TAG, "capture: no sustained voice in %d ms — nothing to send", max_ms);
|
||||
/* pcm_written stays 0 → empty WAV → caller skips posting. */
|
||||
}
|
||||
|
||||
/* Leave RX enabled (full-duplex, as at boot). Disabling it here would make
|
||||
|
||||
@@ -39,6 +39,12 @@ esp_err_t audio_play_async(const char *path);
|
||||
/* Stop current playback immediately. */
|
||||
esp_err_t audio_stop(void);
|
||||
|
||||
/* Mount the microSD card at /sdcard on demand (best-effort, idempotent) and
|
||||
* report whether it is mounted. Unlike playback, this does NOT depend on the
|
||||
* hook state — it lets the REST layer stage a voice pack onto the SD while the
|
||||
* handset is down. Returns true if /sdcard is usable. */
|
||||
bool audio_ensure_sd(void);
|
||||
|
||||
/* True while the audio worker is playing a clip (tone/WAV). The conversation
|
||||
* LISTEN loop polls this to stay half-duplex: never capture while playing
|
||||
* (avoids the earpiece→mic feedback that saturated the line). */
|
||||
|
||||
@@ -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;
|
||||
@@ -251,12 +257,25 @@ static void conv_task(void *arg)
|
||||
go_idle();
|
||||
break;
|
||||
}
|
||||
/* Fetch greeting WAV from gateway and enqueue playback */
|
||||
if (turn_client_greeting(s_sid, s_number,
|
||||
"/spiffs/turn.wav")) {
|
||||
audio_play_async("/spiffs/turn.wav");
|
||||
} else {
|
||||
ESP_LOGW(TAG, "turn_client_greeting failed — proceeding silent");
|
||||
/* Prefer the local SD voice pack (no gateway, no model in RAM):
|
||||
* play /sdcard/voice/greet_<number>.wav when present. Fall back to
|
||||
* the live gateway greeting only when the pack has no clip for this
|
||||
* NPC number (see tools/tts/generate_plip_sd_pack.py). */
|
||||
{
|
||||
char sd_greet[64];
|
||||
snprintf(sd_greet, sizeof(sd_greet),
|
||||
"/sdcard/voice/greet_%s.wav", s_number);
|
||||
FILE *tf = NULL;
|
||||
if (audio_ensure_sd() && (tf = fopen(sd_greet, "rb")) != NULL) {
|
||||
fclose(tf);
|
||||
audio_play_async(sd_greet);
|
||||
ESP_LOGI(TAG, "GREET: local SD pack %s", sd_greet);
|
||||
} else if (turn_client_greeting(s_sid, s_number,
|
||||
"/spiffs/turn.wav")) {
|
||||
audio_play_async("/spiffs/turn.wav");
|
||||
} else {
|
||||
ESP_LOGW(TAG, "no SD clip + gateway greeting failed — silent");
|
||||
}
|
||||
}
|
||||
s_state = STATE_CONNECTED;
|
||||
ESP_LOGI(TAG, "-> CONNECTED");
|
||||
@@ -313,17 +332,13 @@ static void conv_task(void *arg)
|
||||
if (!s_offhook) break;
|
||||
vTaskDelay(pdMS_TO_TICKS(200)); /* let the I2S DMA tail drain */
|
||||
|
||||
/* Kill the earpiece amp during capture: at full volume + 24 dB
|
||||
* mic gain the playback couples into the handset mic at ~50 %
|
||||
* FS and swamps the caller's voice (capture transcribed empty).
|
||||
* PA off = no echo path; restored before the reply plays. */
|
||||
audio_pa_set(false);
|
||||
vTaskDelay(pdMS_TO_TICKS(60)); /* PA mute settle */
|
||||
|
||||
/* Capture caller utterance — earpiece muted, nothing plays. */
|
||||
/* 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);
|
||||
audio_pa_set(true); /* restore for filler/reply */
|
||||
if (!s_offhook) break; /* hung up during capture */
|
||||
|
||||
if (n <= 44) {
|
||||
|
||||
+96
-8
@@ -22,8 +22,10 @@
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <errno.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
#include "audio.h"
|
||||
#include "tones.h"
|
||||
#include "cmd_exec.h"
|
||||
#include "phone.h"
|
||||
#include "conversation.h"
|
||||
@@ -52,7 +54,8 @@
|
||||
|
||||
#define SPIFFS_LABEL "storage"
|
||||
#define SPIFFS_BASE "/spiffs"
|
||||
#define MAX_BODY (256 * 1024)
|
||||
#define MAX_BODY (512 * 1024) /* streamed in 2 KB chunks → RAM-safe; raised
|
||||
so full-length say() hint WAVs fit on SD */
|
||||
|
||||
static volatile bool s_connected = false;
|
||||
static httpd_handle_t s_httpd = NULL;
|
||||
@@ -208,14 +211,39 @@ static esp_err_t handle_file_post(httpd_req_t *req)
|
||||
if (req->content_len <= 0 || req->content_len > MAX_BODY)
|
||||
return send_json(req, "413 Payload Too Large", "{\"error\":\"too large\"}");
|
||||
|
||||
ensure_spiffs();
|
||||
/* Route to the microSD when the path is sd-prefixed ("sd/…", "sdcard/…"
|
||||
* or "/sdcard/…"); otherwise stage in SPIFFS (default). The SD is mounted
|
||||
* on demand here so a voice pack can be staged without an off-hook play. */
|
||||
const char *base;
|
||||
const char *rel = fpath;
|
||||
bool to_sd = false;
|
||||
if (strncmp(fpath, "sd/", 3) == 0) { rel = fpath + 3; to_sd = true; }
|
||||
else if (strncmp(fpath, "sdcard/", 7) == 0) { rel = fpath + 7; to_sd = true; }
|
||||
else if (strncmp(fpath, "/sdcard/", 8) == 0) { rel = fpath + 8; to_sd = true; }
|
||||
|
||||
/* Build full SPIFFS path. */
|
||||
char full[160];
|
||||
if (fpath[0] == '/') {
|
||||
snprintf(full, sizeof(full), "%s%s", SPIFFS_BASE, fpath);
|
||||
if (to_sd) {
|
||||
if (!audio_ensure_sd())
|
||||
return send_json(req, "503 Service Unavailable", "{\"error\":\"no sd card\"}");
|
||||
base = PLIP_SD_MOUNT; /* "/sdcard" */
|
||||
} else {
|
||||
snprintf(full, sizeof(full), "%s/%s", SPIFFS_BASE, fpath);
|
||||
ensure_spiffs();
|
||||
base = SPIFFS_BASE;
|
||||
}
|
||||
while (*rel == '/') rel++; /* avoid a double slash when joining */
|
||||
|
||||
char full[192];
|
||||
snprintf(full, sizeof(full), "%s/%s", base, rel);
|
||||
|
||||
/* Create the immediate parent dir (one level) so a pack can live in a
|
||||
* subfolder like /sdcard/voice/<file>.wav — fopen won't create it. FAT
|
||||
* supports mkdir; SPIFFS is flat so we only do this for the SD path. */
|
||||
if (to_sd) {
|
||||
char *slash = strrchr(full, '/');
|
||||
if (slash && slash != full) {
|
||||
*slash = '\0';
|
||||
mkdir(full, 0775); /* best-effort; ignore EEXIST */
|
||||
*slash = '/';
|
||||
}
|
||||
}
|
||||
|
||||
FILE *f = fopen(full, "wb");
|
||||
@@ -600,6 +628,61 @@ static esp_err_t handle_debug_dacvol(httpd_req_t *req)
|
||||
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)
|
||||
@@ -756,7 +839,7 @@ esp_err_t net_init(void)
|
||||
/* Start HTTP server. */
|
||||
httpd_config_t hcfg = HTTPD_DEFAULT_CONFIG();
|
||||
hcfg.server_port = 80;
|
||||
hcfg.max_uri_handlers = 16;
|
||||
hcfg.max_uri_handlers = 17;
|
||||
hcfg.stack_size = 8192;
|
||||
|
||||
esp_err_t ret = httpd_start(&s_httpd, &hcfg);
|
||||
@@ -801,6 +884,10 @@ esp_err_t net_init(void)
|
||||
.uri = "/debug/getfile", .method = HTTP_GET,
|
||||
.handler = handle_debug_getfile, .user_ctx = NULL,
|
||||
};
|
||||
static const httpd_uri_t uri_debug_miccap = {
|
||||
.uri = "/debug/miccap", .method = HTTP_GET,
|
||||
.handler = handle_debug_miccap, .user_ctx = NULL,
|
||||
};
|
||||
static const httpd_uri_t uri_debug_dacvol = {
|
||||
.uri = "/debug/dacvol", .method = HTTP_GET,
|
||||
.handler = handle_debug_dacvol, .user_ctx = NULL,
|
||||
@@ -834,6 +921,7 @@ esp_err_t net_init(void)
|
||||
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);
|
||||
|
||||
+20
-13
@@ -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;
|
||||
|
||||
@@ -52,3 +52,7 @@ CONFIG_PLIP_HOOK_ACTIVE_HIGH=y
|
||||
# Example: CONFIG_PLIP_GATEWAY_URL="http://192.168.0.175:8401"
|
||||
# CONFIG_PLIP_GATEWAY_TOKEN="testtoken"
|
||||
# Default (Kconfig): http://192.168.0.50:8401 with empty token.
|
||||
|
||||
# Long file names on the SD (voice sample pack uses >8.3 names)
|
||||
CONFIG_FATFS_LFN_HEAP=y
|
||||
CONFIG_FATFS_MAX_LFN=255
|
||||
|
||||
Reference in New Issue
Block a user