Files
ESP32_ZACUS/plip_voice/main/turn_client.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

341 lines
12 KiB
C

/*
* turn_client.c — NPC gateway client for /v1/voice/turn and /v1/voice/reply.
*
* 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.
*
* Stage 3 adds turn_client_reply() which POSTs captured mic audio as
* multipart/form-data to /v1/voice/reply and streams the NPC response WAV.
*/
#include "turn_client.h"
#include "sdkconfig.h"
#include <stdio.h>
#include <string.h>
#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
/* Write chunks when uploading the captured WAV body (4 KB). */
#define UPLOAD_CHUNK 4096
/* Timeout for the full TTS round-trip (connect + synthesis + transfer). */
#define TIMEOUT_MS 90000 /* reply TTS (Kyutai MLX ~0.3x realtime) can take
* tens of seconds; was 30s → reply POST timed out
* (HTTP -1). 90s covers worst-case generation. */
/* Multipart boundary (must not appear in the WAV payload — 16kHz PCM is binary
* so any fixed ASCII boundary is safe). */
#define BOUNDARY "----ZacusPlipBoundary7MA4YWxkTrZu0gW"
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;
}
/* ── turn_client_reply ─────────────────────────────────────────────────────
*
* POST multipart/form-data to /v1/voice/reply with the captured mic WAV.
*
* Multipart layout (each part is CRLF-delimited per RFC 2046):
*
* --<boundary>\r\n
* Content-Disposition: form-data; name="session_id"\r\n\r\n
* <session_id>\r\n
* --<boundary>\r\n
* Content-Disposition: form-data; name="number"\r\n\r\n
* <number>\r\n
* --<boundary>\r\n
* Content-Disposition: form-data; name="audio"; filename="rec.wav"\r\n
* Content-Type: audio/wav\r\n\r\n
* <wav bytes>
* \r\n--<boundary>--\r\n
*
* Content-Length = len(preamble) + wav_len + len(epilogue)
* (known exactly before writing — allows non-chunked POST).
*/
esp_err_t turn_client_reply(const char *session_id,
const char *number,
const uint8_t *wav,
size_t wav_len,
const char *out_path)
{
if (!session_id || !number || !wav || wav_len == 0 || !out_path) {
return ESP_ERR_INVALID_ARG;
}
/* --- Build URL -------------------------------------------------------- */
char url[256];
snprintf(url, sizeof(url), "%s/v1/voice/reply",
CONFIG_PLIP_GATEWAY_URL);
/* --- Build multipart preamble ---------------------------------------- */
/* preamble = 3 parts before the raw wav bytes:
* part 1 — session_id (text field)
* part 2 — number (text field)
* part 3 — audio file header (up to but NOT including the file body)
*/
char preamble[512];
int preamble_len = snprintf(preamble, sizeof(preamble),
"--%s\r\n"
"Content-Disposition: form-data; name=\"session_id\"\r\n\r\n"
"%s\r\n"
"--%s\r\n"
"Content-Disposition: form-data; name=\"number\"\r\n\r\n"
"%s\r\n"
"--%s\r\n"
"Content-Disposition: form-data; name=\"audio\"; filename=\"rec.wav\"\r\n"
"Content-Type: audio/wav\r\n\r\n",
BOUNDARY,
session_id,
BOUNDARY,
number,
BOUNDARY);
if (preamble_len <= 0 || preamble_len >= (int)sizeof(preamble)) {
ESP_LOGE(TAG, "preamble buffer overflow (len=%d)", preamble_len);
return ESP_ERR_INVALID_SIZE;
}
/* --- Build multipart epilogue ---------------------------------------- */
/* epilogue = CRLF after the wav data + closing boundary */
char epilogue[64];
int epilogue_len = snprintf(epilogue, sizeof(epilogue),
"\r\n--%s--\r\n",
BOUNDARY);
if (epilogue_len <= 0 || epilogue_len >= (int)sizeof(epilogue)) {
ESP_LOGE(TAG, "epilogue buffer overflow");
return ESP_ERR_INVALID_SIZE;
}
int content_length = preamble_len + (int)wav_len + epilogue_len;
ESP_LOGI(TAG, "reply POST %s preamble=%d wav=%zu epilogue=%d total=%d",
url, preamble_len, wav_len, epilogue_len, content_length);
/* --- 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 ESP_ERR_NO_MEM;
}
/* Content-Type with boundary */
char ct_header[128];
snprintf(ct_header, sizeof(ct_header),
"multipart/form-data; boundary=%s", BOUNDARY);
esp_http_client_set_header(client, "Content-Type", ct_header);
/* 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 stream body ---------------------------------- */
esp_err_t err = esp_http_client_open(client, content_length);
if (err != ESP_OK) {
ESP_LOGW(TAG, "open %s failed: %s", url, esp_err_to_name(err));
esp_http_client_cleanup(client);
return err;
}
/* Write preamble */
int written = esp_http_client_write(client, preamble, preamble_len);
if (written < 0) {
ESP_LOGW(TAG, "write preamble failed (ret=%d)", written);
esp_http_client_close(client);
esp_http_client_cleanup(client);
return ESP_FAIL;
}
/* Write WAV data in 4 KB chunks */
size_t remaining = wav_len;
const uint8_t *ptr = wav;
while (remaining > 0) {
size_t to_send = (remaining < UPLOAD_CHUNK) ? remaining : UPLOAD_CHUNK;
int wr = esp_http_client_write(client, (const char *)ptr, (int)to_send);
if (wr < 0) {
ESP_LOGW(TAG, "write wav chunk failed (ret=%d)", wr);
esp_http_client_close(client);
esp_http_client_cleanup(client);
return ESP_FAIL;
}
ptr += (size_t)wr;
remaining -= (size_t)wr;
}
/* Write epilogue */
written = esp_http_client_write(client, epilogue, epilogue_len);
if (written < 0) {
ESP_LOGW(TAG, "write epilogue failed (ret=%d)", written);
esp_http_client_close(client);
esp_http_client_cleanup(client);
return ESP_FAIL;
}
/* --- Fetch response headers ------------------------------------------ */
int content_len = esp_http_client_fetch_headers(client);
int status_code = esp_http_client_get_status_code(client);
ESP_LOGI(TAG, "reply HTTP %d content_length=%d", status_code, content_len);
if (status_code != 200) {
ESP_LOGW(TAG, "gateway returned HTTP %d for reply", status_code);
esp_http_client_close(client);
esp_http_client_cleanup(client);
return ESP_ERR_INVALID_RESPONSE;
}
/* --- Stream binary WAV response 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 ESP_ERR_NOT_FOUND;
}
static uint8_t s_reply_chunk[CHUNK_SIZE]; /* static: avoids stack pressure */
int total = 0;
int rd;
while ((rd = esp_http_client_read(client, (char *)s_reply_chunk,
sizeof(s_reply_chunk))) > 0) {
size_t fw = fwrite(s_reply_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, "reply WAV too short (%d bytes) — ignoring", total);
return ESP_ERR_INVALID_SIZE;
}
ESP_LOGI(TAG, "reply WAV %d bytes written to %s", total, out_path);
return ESP_OK;
}