diff --git a/plip_voice/main/CMakeLists.txt b/plip_voice/main/CMakeLists.txt index 20ed31c..b4ad37f 100644 --- a/plip_voice/main/CMakeLists.txt +++ b/plip_voice/main/CMakeLists.txt @@ -10,6 +10,7 @@ idf_component_register( "tones.c" "dialer.c" "conversation.c" + "turn_client.c" INCLUDE_DIRS "." PRIV_REQUIRES driver diff --git a/plip_voice/main/Kconfig.projbuild b/plip_voice/main/Kconfig.projbuild index 5b3b9ed..618c037 100644 --- a/plip_voice/main/Kconfig.projbuild +++ b/plip_voice/main/Kconfig.projbuild @@ -62,4 +62,21 @@ menu "PLIP Voice Configuration" a pulse train, the train is considered complete and the digit is emitted. 200 ms is standard for French rotary dials. + + config PLIP_GATEWAY_URL + string "NPC Gateway Base URL" + default "http://192.168.0.50:8401" + help + Base URL of the PLIP voice gateway (zacus-gateway FastAPI), as seen + from the PLIP on the local LAN. Override at build time or via + sdkconfig.defaults. The turn_client appends /v1/voice/turn. + Example: http://192.168.0.10:8401 (IP of the Mac running the gateway). + + config PLIP_GATEWAY_TOKEN + string "NPC Gateway Bearer Token" + default "" + help + Bearer token sent as "Authorization: Bearer " on every + /v1/voice/turn request. Leave empty to skip the header. + endmenu diff --git a/plip_voice/main/conversation.c b/plip_voice/main/conversation.c index 081428a..bf23ae7 100644 --- a/plip_voice/main/conversation.c +++ b/plip_voice/main/conversation.c @@ -2,12 +2,15 @@ * 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) + * IDLE + off-hook → DIALTONE (tones_dialtone_start) + * DIALTONE + first digit → DIALING (tones_stop) * DIALING + ms_since_last > 3000 → RINGBACK or BUSY (route decision) - * any state + on-hook → IDLE (tones_stop, audio_stop, PA off) + * 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" @@ -17,27 +20,41 @@ #include "dialer.h" #include "tones.h" #include "audio.h" +#include "turn_client.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "esp_log.h" +#include "esp_timer.h" +#include #include #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}; + /* Known numbers: ringback when dialed */ static const char *KNOWN[] = { "12", "3615", "15", "17", "18", "0142738200", NULL @@ -120,6 +137,7 @@ static void conv_task(void *arg) if (is_known(num)) { ESP_LOGI(TAG, "route %s -> known (ringback)", 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); @@ -130,6 +148,49 @@ static void conv_task(void *arg) 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(); + /* 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, dialer_current(), + "/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: + /* Stage 3 will add listen/speak loop here */ + if (!s_offhook) { + go_idle(); + } + break; + case STATE_BUSY: if (!s_offhook) { go_idle(); @@ -144,7 +205,9 @@ void conversation_init(void) s_state = STATE_IDLE; s_offhook = false; s_hook_changed = false; - xTaskCreate(conv_task, "conv", 3072, NULL, 4, NULL); + /* Stack bumped to 6144: STATE_GREET calls esp_http_client (blocking HTTP + * + file I/O) which needs more stack than the baseline 3072. */ + xTaskCreate(conv_task, "conv", 6144, NULL, 4, NULL); ESP_LOGI(TAG, "conversation init"); } diff --git a/plip_voice/main/turn_client.c b/plip_voice/main/turn_client.c new file mode 100644 index 0000000..724f32b --- /dev/null +++ b/plip_voice/main/turn_client.c @@ -0,0 +1,131 @@ +/* + * turn_client.c — NPC gateway client for /v1/voice/turn. + * + * Uses esp_http_client open/write/fetch/read streaming so the binary WAV + * body can be written directly to SPIFFS without a large heap buffer. + * + * Pattern mirrors hook_client.c but synchronous (called from conv_task). + * Timeout is generous (30 s) because TTS synthesis adds latency. + */ + +#include "turn_client.h" + +#include "sdkconfig.h" + +#include +#include + +#include "esp_http_client.h" +#include "esp_log.h" + +#define TAG "turn_client" + +/* WAV header is 44 bytes; anything shorter is certainly not a valid response. */ +#define MIN_WAV_BYTES 44 + +/* Read chunks when streaming the response body. */ +#define CHUNK_SIZE 1024 + +/* Timeout for the full TTS round-trip (connect + synthesis + transfer). */ +#define TIMEOUT_MS 30000 + +bool turn_client_greeting(const char *session_id, + const char *number, + const char *out_path) +{ + /* --- Build URL -------------------------------------------------------- */ + char url[256]; + snprintf(url, sizeof(url), "%s/v1/voice/turn", + CONFIG_PLIP_GATEWAY_URL); + + /* --- Build JSON body -------------------------------------------------- */ + char body[256]; + int body_len = snprintf(body, sizeof(body), + "{\"session_id\":\"%s\",\"number\":\"%s\",\"kind\":\"greeting\"}", + session_id ? session_id : "", + number ? number : ""); + + /* --- Configure client ------------------------------------------------- */ + esp_http_client_config_t cfg = { + .url = url, + .method = HTTP_METHOD_POST, + .timeout_ms = TIMEOUT_MS, + }; + esp_http_client_handle_t client = esp_http_client_init(&cfg); + if (!client) { + ESP_LOGE(TAG, "esp_http_client_init failed"); + return false; + } + + esp_http_client_set_header(client, "Content-Type", "application/json"); + + /* Authorization header — skip if token is empty */ + const char *token = CONFIG_PLIP_GATEWAY_TOKEN; + if (token && token[0] != '\0') { + char auth[128]; + snprintf(auth, sizeof(auth), "Bearer %s", token); + esp_http_client_set_header(client, "Authorization", auth); + } + + /* --- Open connection and write body (streaming POST) ------------------ */ + esp_err_t err = esp_http_client_open(client, body_len); + if (err != ESP_OK) { + ESP_LOGW(TAG, "open %s failed: %s", url, esp_err_to_name(err)); + esp_http_client_cleanup(client); + return false; + } + + int written = esp_http_client_write(client, body, body_len); + if (written < 0) { + ESP_LOGW(TAG, "write body failed (written=%d)", written); + esp_http_client_close(client); + esp_http_client_cleanup(client); + return false; + } + + /* --- Fetch response headers ------------------------------------------ */ + int content_len = esp_http_client_fetch_headers(client); + int status_code = esp_http_client_get_status_code(client); + + if (status_code != 200) { + ESP_LOGW(TAG, "gateway returned HTTP %d (url=%s)", status_code, url); + esp_http_client_close(client); + esp_http_client_cleanup(client); + return false; + } + + ESP_LOGI(TAG, "HTTP 200 from %s (content_length=%d)", url, content_len); + + /* --- Stream binary body into SPIFFS file ------------------------------ */ + FILE *fp = fopen(out_path, "wb"); + if (!fp) { + ESP_LOGE(TAG, "fopen(%s, wb) failed", out_path); + esp_http_client_close(client); + esp_http_client_cleanup(client); + return false; + } + + static uint8_t s_chunk[CHUNK_SIZE]; /* static: avoids stack pressure */ + int total = 0; + int rd; + while ((rd = esp_http_client_read(client, (char *)s_chunk, sizeof(s_chunk))) > 0) { + size_t fw = fwrite(s_chunk, 1, (size_t)rd, fp); + if ((int)fw != rd) { + ESP_LOGW(TAG, "fwrite short: wrote %d of %d bytes", (int)fw, rd); + break; + } + total += rd; + } + + fclose(fp); + esp_http_client_close(client); + esp_http_client_cleanup(client); + + if (total <= MIN_WAV_BYTES) { + ESP_LOGW(TAG, "greeting WAV too short (%d bytes) — ignoring", total); + return false; + } + + ESP_LOGI(TAG, "greeting WAV %d bytes written to %s", total, out_path); + return true; +} diff --git a/plip_voice/main/turn_client.h b/plip_voice/main/turn_client.h new file mode 100644 index 0000000..cd27b9e --- /dev/null +++ b/plip_voice/main/turn_client.h @@ -0,0 +1,35 @@ +#pragma once +/* + * turn_client.h — POST /v1/voice/turn to the NPC gateway and retrieve a WAV response. + * + * Stage 2: greeting fetch (kind="greeting"). + * Stage 3 will add kind="listen" / "speak". + */ +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * turn_client_greeting — POST /v1/voice/turn with kind="greeting". + * + * Sends: { "session_id": session_id, "number": number, "kind": "greeting" } + * Bearer: CONFIG_PLIP_GATEWAY_TOKEN (skipped if empty) + * URL: CONFIG_PLIP_GATEWAY_URL/v1/voice/turn + * + * On success (HTTP 200, body > 44 bytes written to out_path): + * - Streams the binary WAV response into fopen(out_path, "wb"). + * - Returns true. + * + * On failure (network error, non-200, short body): + * - Logs a warning and returns false. + * - Does not crash; caller may proceed silently. + */ +bool turn_client_greeting(const char *session_id, + const char *number, + const char *out_path); + +#ifdef __cplusplus +} +#endif diff --git a/plip_voice/sdkconfig.defaults b/plip_voice/sdkconfig.defaults index 02cd9df..20540ad 100644 --- a/plip_voice/sdkconfig.defaults +++ b/plip_voice/sdkconfig.defaults @@ -43,3 +43,8 @@ CONFIG_FREERTOS_IDLE_TASK_STACKSIZE=2048 # Zacus master URL — default matches the lab IP # CONFIG_PLIP_MASTER_URL="http://192.168.0.188" + +# NPC gateway (Stage 2) — override in sdkconfig (NOT committed) for local testing. +# Example: CONFIG_PLIP_GATEWAY_URL="http://192.168.0.175:8401" +# CONFIG_PLIP_GATEWAY_TOKEN="testtoken" +# Default (Kconfig): http://192.168.0.50:8401 with empty token.