414 lines
16 KiB
C
414 lines
16 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"
|
||
#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 <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 600 /* end-of-speech VAD: shorter = snappier reply (was 800) */
|
||
#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 */
|
||
} 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};
|
||
|
||
/* Incoming-call mode: an NPC "calls" the phone. conversation_arm_incoming()
|
||
* stores the caller's persona number 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 volatile bool s_incoming_armed = false;
|
||
|
||
/* 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;
|
||
}
|
||
|
||
static void go_idle(void)
|
||
{
|
||
tones_stop();
|
||
audio_stop();
|
||
audio_pa_set(false);
|
||
dialer_reset();
|
||
#if CONFIG_PLIP_DIAL_DTMF
|
||
dtmf_stop();
|
||
#endif
|
||
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();
|
||
tones_stop();
|
||
dialer_reset();
|
||
s_offhook = true;
|
||
snprintf(s_number, sizeof(s_number), "%s", s_incoming_number);
|
||
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);
|
||
}
|
||
|
||
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_incoming_armed) {
|
||
/* INCOMING call answered (phone.c detected the edge). */
|
||
enter_incoming_greet();
|
||
} 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();
|
||
}
|
||
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_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;
|
||
}
|
||
/* 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");
|
||
}
|
||
s_state = STATE_CONNECTED;
|
||
ESP_LOGI(TAG, "-> CONNECTED");
|
||
break;
|
||
|
||
case STATE_CONNECTED:
|
||
#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 */
|
||
|
||
/* 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. */
|
||
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) {
|
||
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
|
||
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)
|
||
{
|
||
snprintf(s_incoming_number, sizeof(s_incoming_number), "%s",
|
||
(number && number[0]) ? number : "17");
|
||
s_incoming_armed = true;
|
||
phone_ring_start();
|
||
ESP_LOGI(TAG, "incoming call armed (num=%s) — ringing until pickup",
|
||
s_incoming_number);
|
||
}
|