Files
ESP32_ZACUS/plip_voice/main/conversation.c
T
clement b203f0e4de
CI / platformio (pull_request) Failing after 3m46s
CI / platformio (push) Failing after 4m1s
plip: optional auto story-ring (off by default)
Escape-room mechanic: the retro phone can call the players on its own
and tell them a story when they pick up.

Approach: a story_ring_task rings at a random 15-30 min interval (only
when the line is idle and a story pack is on the SD). Picking up routes
through enter_story_from_ring(), which silences the bell and launches a
random story via plip_gamebook_begin_random() straight into the gamebook
state — no menu. If nobody answers within 1 min the bell stops and the
next ring is rescheduled; hanging up mid-ring also silences it.

The whole feature is gated behind CONFIG_PLIP_AUTO_RING (default n), so
the phone stays silent unless explicitly enabled in menuconfig. The hook
pickup paths (raw SLIC poll + debounced edge) mirror the existing NPC
incoming-call handling, which is unreliable while the bell rings.
2026-06-20 21:08:56 +02:00

589 lines
24 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* conversation.c — PLIP telephone conversation state machine.
*
* States: IDLE → DIALTONE → DIALING → RINGBACK | BUSY
* RINGBACK (after ~2 s) → GREET → CONNECTED
*
* Transitions:
* IDLE + off-hook → DIALTONE (tones_dialtone_start)
* DIALTONE + first digit → DIALING (tones_stop)
* DIALING + ms_since_last > 3000 → RINGBACK or BUSY (route decision)
* RINGBACK + 2 s elapsed → GREET (tones_stop, turn_client_greeting, play WAV)
* GREET + play enqueued → CONNECTED (Stage 3: listen loop)
* any state + on-hook → IDLE (tones_stop, audio_stop, PA off)
*
* Routing table (known numbers → ringback; unknown → busy after 3 s silence):
* "12", "3615", "15", "17", "18", "0142738200"
*/
#include "conversation.h"
#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 */
#define RINGBACK_GREET_MS 2000
typedef enum {
STATE_IDLE,
STATE_DIALTONE,
STATE_DIALING,
STATE_RINGBACK,
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;
static volatile bool s_offhook = false;
static volatile bool s_hook_changed = false;
/* Ringback start timestamp (µs, from esp_timer_get_time) */
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[] = {
"12", "3615", "15", "17", "18", "0142738200", NULL
};
static bool is_known(const char *num)
{
for (int i = 0; KNOWN[i] != NULL; i++) {
if (strcmp(num, KNOWN[i]) == 0) return true;
}
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 1530 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;
ESP_LOGI(TAG, "conversation task ready");
for (;;) {
vTaskDelay(pdMS_TO_TICKS(50));
/* Handle hook change events */
if (s_hook_changed) {
s_hook_changed = false;
if (!s_offhook) {
/* On-hook: always go idle regardless of current state */
if (s_state != STATE_IDLE) {
ESP_LOGI(TAG, "on-hook -> IDLE");
go_idle();
}
continue;
} else {
/* Off-hook from idle */
if (s_state == STATE_IDLE) {
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;
}
}
/* State machine polling */
switch (s_state) {
case STATE_IDLE:
/* 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) {
go_idle();
break;
}
/* First digit received → stop dialtone, enter dialing */
if (!dialer_idle()) {
tones_stop();
s_state = STATE_DIALING;
ESP_LOGI(TAG, "first digit -> DIALING");
}
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();
break;
}
/* Wait for 3 s of silence after last digit, then route */
if (dialer_ms_since_last() > 3000) {
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;
} else {
ESP_LOGI(TAG, "route %s -> unknown (busy)", num);
tones_busy_start();
s_state = STATE_BUSY;
}
}
break;
case STATE_RINGBACK:
if (!s_offhook) {
go_idle();
break;
}
{
int64_t elapsed_ms =
(esp_timer_get_time() - s_ringback_start_us) / 1000;
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());
ESP_LOGI(TAG, "ringback done -> GREET (sid=%s num=%s)",
s_sid, dialer_current());
s_state = STATE_GREET;
}
}
break;
case STATE_GREET:
if (!s_offhook) {
go_idle();
break;
}
/* Prefer the local SD voice pack (no gateway, no model in RAM):
* play /sdcard/voice/greet_<number>.wav when present. Fall back to
* the live gateway greeting only when the pack has no clip for this
* NPC number (see tools/tts/generate_plip_sd_pack.py). */
{
char sd_greet[64];
snprintf(sd_greet, sizeof(sd_greet),
"/sdcard/voice/greet_%s.wav", s_number);
FILE *tf = NULL;
if (audio_ensure_sd() && (tf = fopen(sd_greet, "rb")) != NULL) {
fclose(tf);
audio_play_async(sd_greet);
ESP_LOGI(TAG, "GREET: local SD pack %s", sd_greet);
} else if (turn_client_greeting(s_sid, s_number,
"/spiffs/turn.wav")) {
audio_play_async("/spiffs/turn.wav");
} else {
ESP_LOGW(TAG, "no SD clip + gateway greeting failed — silent");
}
}
/* Scripted offline call: chain this scene's hint from the SD pack
* right after the greeting (the audio queue plays them back-to-back).
* Fully local — no gateway, no model. */
if (s_scene[0] != '\0' && audio_ensure_sd()) {
char slug[40], hint[80];
scene_slug(s_scene, slug, sizeof(slug));
snprintf(hint, sizeof(hint),
"/sdcard/voice/hint_%s_l1_0.wav", slug);
FILE *hf = fopen(hint, "rb");
if (hf != NULL) {
fclose(hf);
audio_play_async(hint);
ESP_LOGI(TAG, "GREET: chained SD hint %s", hint);
} else {
ESP_LOGI(TAG, "GREET: no SD hint for scene %s (%s)",
s_scene, hint);
}
}
s_state = STATE_CONNECTED;
ESP_LOGI(TAG, "-> CONNECTED");
break;
case STATE_CONNECTED:
/* Scripted offline call (greeting + SD hint already queued): there's
* no live two-way conversation to run — just let the clips play out
* and wait for the player to hang up. Skips the gateway/model listen
* loop entirely. The global on-hook check returns us to IDLE. */
if (s_scene[0] != '\0') {
vTaskDelay(pdMS_TO_TICKS(REPLY_POLL_MS));
break;
}
#if CONFIG_PLIP_VOICE_REPLY
/*
* Stage 3 — LISTEN loop.
*
* 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:
if (!s_offhook) {
go_idle();
}
break;
}
}
}
void conversation_init(void)
{
s_state = STATE_IDLE;
s_offhook = false;
s_hook_changed = false;
/* 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 1530 min when idle. */
xTaskCreate(story_ring_task, "storyring", 3072, NULL, 3, NULL);
#endif
ESP_LOGI(TAG, "conversation init");
}
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 : "-");
}