Files
ESP32_ZACUS/plip_voice/main/conversation.c
T
2026-06-16 19:18:45 +02:00

414 lines
16 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"
#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);
}