aa7ae277ed
- 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
341 lines
12 KiB
C
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;
|
|
}
|