Files
ESP32_ZACUS/plip_voice/main/conversation.c
T
clement aa7ae277ed
CI / platformio (pull_request) Failing after 4m0s
CI / platformio (push) Failing after 14m58s
feat(plip): voice loop + DTMF + ring cadence
- hook polarity active-HIGH + auto-resync (was LOW)
- ring cadence FT 1.5s ON / 3.5s OFF
- DTMF Goertzel decoder (dtmf.c/h) + rotary debounce
- LISTEN half-duplex: capture → /v1/voice/reply → play
- WAV playback buffered PSRAM + mono→stereo upmix
- SPIFFS mount at boot for pre-loaded greetings
- ES8388: DAC digital vol + mic PGA + GPIO INPUT_OUTPUT
- turn_client multipart + 90s timeout + fixed routing
- debug endpoints: vol/dacvol/offhook/getfile/hookmon
2026-06-15 21:12:33 +02:00

358 lines
13 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 "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 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};
/* 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");
}
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: start dial tone if we were idle */
if (s_state == STATE_IDLE) {
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:
break; /* nothing to do */
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(250)); /* let a just-queued clip start */
while (s_offhook && audio_is_playing()) {
vTaskDelay(pdMS_TO_TICKS(50));
}
if (!s_offhook) break;
vTaskDelay(pdMS_TO_TICKS(200)); /* line settle after playback */
/* Capture player utterance — nothing is playing now. */
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
ESP_LOGI(TAG, "conversation init");
}
void conversation_on_hook_change(bool offhook)
{
s_offhook = offhook;
s_hook_changed = true;
}