b203f0e4de
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.
589 lines
24 KiB
C
589 lines
24 KiB
C
/*
|
||
* 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 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;
|
||
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 15–30 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 : "-");
|
||
}
|