From aa7ae277ed5608f4bafd6691047db578174ff47f Mon Sep 17 00:00:00 2001 From: clement Date: Mon, 15 Jun 2026 21:12:33 +0200 Subject: [PATCH] feat(plip): voice loop + DTMF + ring cadence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- plip_voice/main/CMakeLists.txt | 1 + plip_voice/main/Kconfig.projbuild | 32 ++- plip_voice/main/audio.c | 136 +++++++------ plip_voice/main/audio.h | 5 + plip_voice/main/conversation.c | 147 +++++++++++++- plip_voice/main/dtmf.c | 323 ++++++++++++++++++++++++++++++ plip_voice/main/dtmf.h | 57 ++++++ plip_voice/main/es8388.c | 32 ++- plip_voice/main/es8388.h | 5 + plip_voice/main/net.c | 177 +++++++++++++++- plip_voice/main/phone.c | 132 ++++++++++-- plip_voice/main/phone.h | 5 + plip_voice/main/slic.c | 77 +++++-- plip_voice/main/turn_client.c | 213 +++++++++++++++++++- plip_voice/main/turn_client.h | 33 ++- plip_voice/sdkconfig.defaults | 2 +- 16 files changed, 1263 insertions(+), 114 deletions(-) create mode 100644 plip_voice/main/dtmf.c create mode 100644 plip_voice/main/dtmf.h diff --git a/plip_voice/main/CMakeLists.txt b/plip_voice/main/CMakeLists.txt index 0ba7d89..e1b89d9 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" + "dtmf.c" "turn_client.c" "slic.c" INCLUDE_DIRS "." diff --git a/plip_voice/main/Kconfig.projbuild b/plip_voice/main/Kconfig.projbuild index 81dff9d..fbefcc5 100644 --- a/plip_voice/main/Kconfig.projbuild +++ b/plip_voice/main/Kconfig.projbuild @@ -31,7 +31,7 @@ menu "PLIP Voice Configuration" config PLIP_SPEAKER_VOLUME int "Default Speaker Volume (0-100)" - default 70 + default 80 range 0 100 help Default speaker output volume at boot. @@ -71,6 +71,18 @@ 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_DIAL_DTMF + bool "Enable DTMF (touch-tone) dialing via Goertzel" + default n + help + When enabled, a background task reads 20 ms microphone frames and + runs a Goertzel-based DTMF detector (8 frequencies: 697-1633 Hz). + Confirmed digits (≥ 40 ms tone, with twist and dominance guards) + are pushed to the dialer just like rotary pulses. + The detector is active only between off-hook and the start of the + NPC greeting; it is disarmed during voice capture (CONNECTED state). + Can be combined with PLIP_DIAL_PULSE: whichever source detects a + digit first wins. Default off — enable for touch-tone handsets. config PLIP_GATEWAY_URL string "NPC Gateway Base URL" @@ -88,4 +100,22 @@ menu "PLIP Voice Configuration" Bearer token sent as "Authorization: Bearer " on every /v1/voice/turn request. Leave empty to skip the header. + config PLIP_VOICE_REPLY + bool "Enable Stage-3 conversational LISTEN loop (capture -> /v1/voice/reply -> play)" + default n + help + When enabled, after the NPC greeting is played (STATE_CONNECTED), + the firmware enters a continuous listen loop: + 1. Capture mic audio (up to 8 s, VAD-gated) via audio_capture_wav(). + 2. POST the captured WAV as multipart/form-data to + CONFIG_PLIP_GATEWAY_URL/v1/voice/reply (STT + NPC reply via Kyutai). + 3. Play the NPC response WAV from /spiffs/reply.wav. + 4. Repeat until the handset is hung up. + Requires the gateway (zacus-gateway FastAPI) to be reachable and + the /v1/voice/reply endpoint to be operational. + Capture buffer (~256 KB for 8 s) is allocated from PSRAM when + available; falls back to internal heap with reduced duration (4 s). + Leave OFF (default) to keep STATE_CONNECTED as a terminal state + (Stage 2 behaviour — greeting only, no further interaction). + endmenu diff --git a/plip_voice/main/audio.c b/plip_voice/main/audio.c index 573f934..d9d9c5b 100644 --- a/plip_voice/main/audio.c +++ b/plip_voice/main/audio.c @@ -69,6 +69,7 @@ static QueueHandle_t s_queue; static i2s_chan_handle_t s_spk_handle = NULL; static i2s_chan_handle_t s_mic_handle = NULL; static volatile bool s_stop_req = false; +static volatile bool s_playing = false; /* true while the worker plays a clip */ static bool s_sd_mounted = false; static bool s_spiffs_mounted = false; @@ -81,7 +82,7 @@ static void ensure_spiffs_audio(void) .base_path = "/spiffs", .partition_label = "storage", .max_files = 8, - .format_if_mount_failed = false, + .format_if_mount_failed = true, /* format a blank/corrupt partition at boot */ }; esp_err_t ret = esp_vfs_spiffs_register(&conf); if (ret == ESP_OK || ret == ESP_ERR_INVALID_STATE /* already mounted */) { @@ -167,45 +168,6 @@ static esp_err_t parse_wav_header(const uint8_t *buf, size_t len, wav_info_t *ou return ESP_OK; } -/* ── Streaming helpers ───────────────────────────────────────────────────── */ - -/* Stream raw PCM-16 data to the speaker I2S channel in chunks. */ -static void stream_pcm(const uint8_t *data, size_t byte_len) -{ - size_t offset = 0; - while (!s_stop_req && offset < byte_len) { - size_t chunk = (byte_len - offset < 2048) ? (byte_len - offset) : 2048; - size_t written = 0; - esp_err_t ret = i2s_channel_write(s_spk_handle, - data + offset, chunk, - &written, pdMS_TO_TICKS(500)); - if (ret != ESP_OK) { - ESP_LOGW(TAG, "I2S write error: %s", esp_err_to_name(ret)); - break; - } - offset += chunk; - } -} - -/* Play WAV from an in-memory buffer. */ -static void play_wav_buf(const uint8_t *buf, size_t len) -{ - wav_info_t wi = {0}; - esp_err_t ret = parse_wav_header(buf, len, &wi); - if (ret != ESP_OK) { - ESP_LOGW(TAG, "WAV parse error: %s", esp_err_to_name(ret)); - audio_play_tone(880.0f, 200); - return; - } - if (wi.bits_per_sample != 16) { - ESP_LOGW(TAG, "WAV: %d-bit not supported (need 16-bit)", wi.bits_per_sample); - return; - } - ESP_LOGI(TAG, "WAV: %"PRIu32" Hz %d-bit %d ch, %"PRIu32" bytes PCM", - wi.sample_rate, wi.bits_per_sample, wi.channels, wi.data_size); - stream_pcm(buf + wi.data_offset, wi.data_size); -} - /* WAV streaming chunk size — keeps heap usage well under 8 KB. */ #define PLAY_CHUNK_BYTES 4096 @@ -279,28 +241,62 @@ static void play_wav_file(const char *path) return; } - /* Stream PCM to I2S in small chunks — no large malloc needed. */ - static uint8_t s_play_chunk[PLAY_CHUNK_BYTES]; /* static: avoids stack pressure */ - uint32_t remaining = wi.data_size; - size_t total_written = 0; + /* The I2S TX slot is STEREO @ SAMPLE_RATE. A mono WAV must be expanded to + * L+R or it is consumed at 2x rate (the "chipmunk" fast/high-pitch bug). + * A WAV whose rate differs from SAMPLE_RATE would also play at the wrong + * speed — warn (the gateway TTS always returns 16 kHz, matching). */ + if (wi.sample_rate != SAMPLE_RATE) { + ESP_LOGW(TAG, "WAV rate %"PRIu32" Hz != I2S %d Hz — playback speed will be off", + wi.sample_rate, SAMPLE_RATE); + } + const bool mono = (wi.channels == 1); - while (!s_stop_req && remaining > 0) { - uint32_t to_read = (remaining < PLAY_CHUNK_BYTES) ? remaining : PLAY_CHUNK_BYTES; - size_t n = fread(s_play_chunk, 1, to_read, f); - if (n == 0) break; - size_t i2s_written = 0; - esp_err_t ret = i2s_channel_write(s_spk_handle, s_play_chunk, n, - &i2s_written, pdMS_TO_TICKS(500)); + /* Load the ENTIRE PCM into PSRAM BEFORE playing. Streaming fread() from + * SPIFFS *between* I2S writes stalls the DMA → underrun → audible + * distortion ("saturation"). Diagnostic confirmed: the stored WAV is clean + * (0% clip) and RAM-generated tones play clean, but SPIFFS-streamed WAVs + * distorted. Reading it all up-front = zero file I/O during playback. */ + uint8_t *pcm = heap_caps_malloc(wi.data_size, MALLOC_CAP_SPIRAM); + if (!pcm) pcm = malloc(wi.data_size); + if (!pcm) { + ESP_LOGE(TAG, "play: OOM for %"PRIu32"-byte PCM buffer", wi.data_size); + fclose(f); + return; + } + size_t pcm_len = fread(pcm, 1, wi.data_size, f); + fclose(f); + + static int16_t s_stereo_chunk[PLAY_CHUNK_BYTES]; /* mono→stereo scratch */ + size_t off = 0; + size_t total_written = 0; + const size_t step = mono ? (PLAY_CHUNK_BYTES / 2) : PLAY_CHUNK_BYTES; + + while (!s_stop_req && off < pcm_len) { + size_t bytes = (pcm_len - off < step) ? (pcm_len - off) : step; + const uint8_t *out = pcm + off; + size_t out_len = bytes; + if (mono) { + /* Duplicate each 16-bit mono sample into L and R. */ + size_t samples = bytes / 2; + const int16_t *src = (const int16_t *)(pcm + off); + for (size_t k = 0; k < samples; k++) { + s_stereo_chunk[2 * k] = src[k]; + s_stereo_chunk[2 * k + 1] = src[k]; + } + out = (const uint8_t *)s_stereo_chunk; + out_len = samples * 4; /* 2 channels × 2 bytes */ + } + size_t w = 0; + esp_err_t ret = i2s_channel_write(s_spk_handle, out, out_len, &w, pdMS_TO_TICKS(500)); if (ret != ESP_OK) { ESP_LOGW(TAG, "I2S write error: %s", esp_err_to_name(ret)); break; } - total_written += i2s_written; - remaining -= (uint32_t)n; + total_written += w; + off += bytes; } - - fclose(f); - float dur = (float)total_written / (float)(wi.sample_rate * wi.channels * (wi.bits_per_sample / 8)); + free(pcm); + float dur = (float)total_written / (float)(SAMPLE_RATE * 2 * 2); /* 16k stereo 16-bit */ ESP_LOGI(TAG, "play done: %zu bytes written, %.2fs", total_written, dur); } @@ -385,6 +381,7 @@ static void audio_worker_task(void *arg) ESP_LOGI(TAG, "play ignored: on-hook (handset down)"); break; } + s_playing = true; const char *p = cmd.path; if (!p || !*p || strncmp(p, "embedded:", 9) == 0) { ESP_LOGI(TAG, "play: embedded cue"); @@ -393,6 +390,7 @@ static void audio_worker_task(void *arg) ESP_LOGI(TAG, "play: %s", p); play_wav_file(p); } + s_playing = false; break; } default: @@ -409,6 +407,11 @@ void audio_pa_set(bool enable) ESP_LOGI(TAG, "PA %s", enable ? "ON" : "OFF"); } +bool audio_is_playing(void) +{ + return s_playing; +} + esp_err_t audio_init(void) { /* 1. ES8388 I2C init + register sequence. */ @@ -418,6 +421,10 @@ esp_err_t audio_init(void) return ret; } + /* Apply the configured output volume. es8388_init() leaves OUT2 at 0 dB + * (max) — too loud for a handset earpiece — so set it explicitly here. */ + es8388_set_volume(CONFIG_PLIP_SPEAKER_VOLUME); + /* 2. Create I2S channels (TX = speaker, RX = mic — full-duplex pair). */ i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(PLIP_I2S_NUM, I2S_ROLE_MASTER); @@ -513,6 +520,10 @@ esp_err_t audio_init(void) 8192, NULL, 5, NULL, 0); if (ok != pdPASS) return ESP_ERR_NO_MEM; + /* Mount SPIFFS at init (not lazily) so turn_client can WRITE the NPC WAV to + * /spiffs before any playback has triggered the lazy mount. */ + ensure_spiffs_audio(); + ESP_LOGI(TAG, "audio init OK (I2S TX ready, RX handle allocated, ES8388 live)"); return ESP_OK; } @@ -564,9 +575,11 @@ int audio_capture_wav(uint8_t *out, size_t out_max, int max_ms, int silence_ms) return -1; } - /* Enable RX while keeping TX running (TX drives MCLK/BCLK/WS for the codec). */ + /* RX is already enabled at boot (full-duplex). Calling enable again returns + * ESP_ERR_INVALID_STATE — that's fine, it just means RX is already running. + * Only a genuinely different error is fatal. */ esp_err_t ret = i2s_channel_enable(s_mic_handle); - if (ret != ESP_OK) { + if (ret != ESP_OK && ret != ESP_ERR_INVALID_STATE) { ESP_LOGE(TAG, "capture: i2s_channel_enable(RX): %s", esp_err_to_name(ret)); free(rx_buf); return -1; @@ -639,8 +652,9 @@ int audio_capture_wav(uint8_t *out, size_t out_max, int max_ms, int silence_ms) total_frames = f + 1; } - /* Disable RX; TX was never stopped. */ - i2s_channel_disable(s_mic_handle); + /* Leave RX enabled (full-duplex, as at boot). Disabling it here would make + * the next capture's enable a no-op INVALID_STATE AND break other RX users + * (streaming capture / DTMF) that assume RX stays running. */ free(rx_buf); if (pcm_written == 0) { @@ -706,8 +720,6 @@ int audio_capture_begin(int max_ms, int silence_ms) return 0; } - - /* * Read one 20 ms frame from the mic, downmix stereo→mono. * mono_out must hold n_samples (320) int16_t values. diff --git a/plip_voice/main/audio.h b/plip_voice/main/audio.h index 278dd0c..cc41fdb 100644 --- a/plip_voice/main/audio.h +++ b/plip_voice/main/audio.h @@ -39,6 +39,11 @@ esp_err_t audio_play_async(const char *path); /* Stop current playback immediately. */ esp_err_t audio_stop(void); +/* True while the audio worker is playing a clip (tone/WAV). The conversation + * LISTEN loop polls this to stay half-duplex: never capture while playing + * (avoids the earpiece→mic feedback that saturated the line). */ +bool audio_is_playing(void); + /* Start ring tone cadence (ON 1s / OFF 2s) at ~440 Hz. Continues until * audio_stop() is called. Non-blocking — spawns an internal task. */ esp_err_t audio_ring_start(void); diff --git a/plip_voice/main/conversation.c b/plip_voice/main/conversation.c index bf23ae7..f6fbdec 100644 --- a/plip_voice/main/conversation.c +++ b/plip_voice/main/conversation.c @@ -20,16 +20,38 @@ #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 #include +#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 */ @@ -54,6 +76,11 @@ 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[] = { @@ -74,6 +101,9 @@ static void go_idle(void) audio_stop(); audio_pa_set(false); dialer_reset(); +#if CONFIG_PLIP_DIAL_DTMF + dtmf_stop(); +#endif s_state = STATE_IDLE; ESP_LOGI(TAG, "-> IDLE"); } @@ -101,6 +131,9 @@ static void conv_task(void *arg) 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"); } @@ -136,6 +169,9 @@ static void conv_task(void *arg) 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; @@ -158,6 +194,10 @@ static void conv_task(void *arg) 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()); @@ -174,7 +214,7 @@ static void conv_task(void *arg) break; } /* Fetch greeting WAV from gateway and enqueue playback */ - if (turn_client_greeting(s_sid, dialer_current(), + if (turn_client_greeting(s_sid, s_number, "/spiffs/turn.wav")) { audio_play_async("/spiffs/turn.wav"); } else { @@ -185,10 +225,104 @@ static void conv_task(void *arg) break; case STATE_CONNECTED: - /* Stage 3 will add listen/speak loop here */ +#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: @@ -205,9 +339,14 @@ void conversation_init(void) s_state = STATE_IDLE; s_offhook = false; s_hook_changed = false; - /* Stack bumped to 6144: STATE_GREET calls esp_http_client (blocking HTTP - * + file I/O) which needs more stack than the baseline 3072. */ + /* 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"); } diff --git a/plip_voice/main/dtmf.c b/plip_voice/main/dtmf.c new file mode 100644 index 0000000..0b742a7 --- /dev/null +++ b/plip_voice/main/dtmf.c @@ -0,0 +1,323 @@ +/* + * dtmf.c — DTMF (touch-tone) detector using the Goertzel algorithm. + * + * Detection pipeline per 20 ms frame (320 samples @ 16 kHz): + * 1. Compute Goertzel power for 8 DTMF frequencies (4 low + 4 high groups). + * 2. Find the strongest frequency in each group (best_low, best_high). + * 3. Apply three guards: + * a) Absolute energy threshold — both must exceed DTMF_ENERGY_THRESH. + * b) Group dominance ratio — winner must be > DTMF_DOMINANT_RATIO× + * times the second-best in the same group. + * c) Twist guard — energy ratio (low/high) must be within + * [1/DTMF_TWIST_MAX, DTMF_TWIST_MAX]. + * 4. Debounce: + * - Require DTMF_CONFIRM_FRAMES consecutive matching frames to emit. + * - Require DTMF_RELEASE_FRAMES of silence/mismatch before re-arming. + * + * Threshold rationale: + * DTMF_ENERGY_THRESH = 4000000 + * Goertzel power is mean-squared × N². A -30 dBFS sine at 16-bit PCM + * (amplitude ≈ 1000 LSB) gives power ≈ (1000²/2) × 320² / 320 ≈ 1.6e8. + * -50 dBFS (amplitude ≈ 100) gives ≈ 1.6e6. We set the floor at 4e6 + * (≈ -47 dBFS) to reject noise while allowing quiet handset microphones. + * + * DTMF_DOMINANT_RATIO = 4.0f (≈ 6 dB separation within a group) + * A real DTMF tone drives exactly one row and one column. If two + * frequencies in the same group are within 6 dB of each other, it is + * more likely noise or voice than a keypad press. + * + * DTMF_TWIST_MAX = 8.0f (≈ 9 dB) + * ITU-T Q.24 allows up to ±8 dB twist between low/high group. We use + * 8× power ratio which corresponds to ≈ 9 dB — slightly relaxed to + * accommodate the varied mic responses of vintage telephone handsets. + * + * DTMF_CONFIRM_FRAMES = 2 (2 × 20 ms = 40 ms minimum tone duration) + * ITU-T Q.24 specifies ≥ 40 ms tone duration for valid DTMF. + * + * DTMF_RELEASE_FRAMES = 1 (≥ 20 ms inter-digit silence required) + * Prevents a single sustained keypress from re-triggering. + */ + +/* sdkconfig.h must be included before any CONFIG_* test so the preprocessor + * has the symbol defined when the #if guard below is evaluated. */ +#include "sdkconfig.h" + +#if CONFIG_PLIP_DIAL_DTMF + +#include "dtmf.h" +#include "audio.h" +#include "dialer.h" + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "esp_log.h" + +#include +#include +#include +#include + +#define TAG "dtmf" + +/* ------------------------------------------------------------------------- + * Detection thresholds (see rationale in file header) + * ---------------------------------------------------------------------- */ + +/* Minimum Goertzel power (mean-squared × N) for a frequency to be considered + * "present". Both the row and column candidate must exceed this. */ +#define DTMF_ENERGY_THRESH 4000000LL + +/* Minimum ratio of best/second-best power within a group. Below this the + * group is ambiguous (noise / voice) and the frame is rejected. */ +#define DTMF_DOMINANT_RATIO 4.0f + +/* Maximum ratio of low-group / high-group power (and its inverse). + * Exceeding this means one group is far stronger than expected for DTMF. */ +#define DTMF_TWIST_MAX 8.0f + +/* Consecutive frames required before a digit is reported. */ +#define DTMF_CONFIRM_FRAMES 2 + +/* Frames of "no valid tone" required between two reports. */ +#define DTMF_RELEASE_FRAMES 1 + +/* ------------------------------------------------------------------------- + * DTMF frequency table + * ---------------------------------------------------------------------- */ + +#define NUM_LOW 4 +#define NUM_HIGH 4 + +static const float LOW_FREQS[NUM_LOW] = { 697.0f, 770.0f, 852.0f, 941.0f }; +static const float HIGH_FREQS[NUM_HIGH] = { 1209.0f, 1336.0f, 1477.0f, 1633.0f }; + +/* DTMF matrix: [low_idx][high_idx] → character. + * Column 3 (1633 Hz) maps to ABCD which are unused on standard phones → '\0'. */ +static const char DTMF_MATRIX[NUM_LOW][NUM_HIGH] = { + { '1', '2', '3', '\0' }, /* 697 Hz row */ + { '4', '5', '6', '\0' }, /* 770 Hz row */ + { '7', '8', '9', '\0' }, /* 852 Hz row */ + { '*', '0', '#', '\0' }, /* 941 Hz row */ +}; + +/* ------------------------------------------------------------------------- + * Goertzel coefficient cache (precomputed at first call) + * ---------------------------------------------------------------------- */ + +#define FS 16000 /* sample rate */ +#define N_SAMP 320 /* frame size */ + +static bool s_coeff_ready = false; +static float s_low_coeff[NUM_LOW]; +static float s_high_coeff[NUM_HIGH]; + +static void precompute_coeffs(void) +{ + for (int i = 0; i < NUM_LOW; i++) { + float k = (float)N_SAMP * LOW_FREQS[i] / (float)FS; + s_low_coeff[i] = 2.0f * cosf(2.0f * (float)M_PI * k / (float)N_SAMP); + } + for (int i = 0; i < NUM_HIGH; i++) { + float k = (float)N_SAMP * HIGH_FREQS[i] / (float)FS; + s_high_coeff[i] = 2.0f * cosf(2.0f * (float)M_PI * k / (float)N_SAMP); + } + s_coeff_ready = true; +} + +/* ------------------------------------------------------------------------- + * Goertzel power for a single frequency + * Power = Q1² + Q2² − Q1·Q2·coeff (unnormalised, proportional to amplitude²) + * ---------------------------------------------------------------------- */ + +static float goertzel_power(const int16_t *samples, int n, float coeff) +{ + float q1 = 0.0f, q2 = 0.0f; + for (int i = 0; i < n; i++) { + float q0 = coeff * q1 - q2 + (float)samples[i]; + q2 = q1; + q1 = q0; + } + return q1 * q1 + q2 * q2 - q1 * q2 * coeff; +} + +/* ------------------------------------------------------------------------- + * Public API: dtmf_detect_frame + * ---------------------------------------------------------------------- */ + +char dtmf_detect_frame(const int16_t *mono, int n) +{ + if (n <= 0 || !mono) return '\0'; + + if (!s_coeff_ready) precompute_coeffs(); + + /* Compute Goertzel power for all 8 frequencies */ + float low_pow[NUM_LOW], high_pow[NUM_HIGH]; + for (int i = 0; i < NUM_LOW; i++) + low_pow[i] = goertzel_power(mono, n, s_low_coeff[i]); + for (int i = 0; i < NUM_HIGH; i++) + high_pow[i] = goertzel_power(mono, n, s_high_coeff[i]); + + /* Find strongest in each group */ + int best_low = 0, best_high = 0; + float max_low = low_pow[0], max_high = high_pow[0]; + for (int i = 1; i < NUM_LOW; i++) { + if (low_pow[i] > max_low) { max_low = low_pow[i]; best_low = i; } + } + for (int i = 1; i < NUM_HIGH; i++) { + if (high_pow[i] > max_high) { max_high = high_pow[i]; best_high = i; } + } + + /* Guard (a): absolute energy threshold */ + if ((int64_t)max_low < DTMF_ENERGY_THRESH || (int64_t)max_high < DTMF_ENERGY_THRESH) + goto no_tone; + + /* Guard (b): group dominance — find second-best in each group */ + { + float second_low = 0.0f, second_high = 0.0f; + for (int i = 0; i < NUM_LOW; i++) { + if (i != best_low && low_pow[i] > second_low) + second_low = low_pow[i]; + } + for (int i = 0; i < NUM_HIGH; i++) { + if (i != best_high && high_pow[i] > second_high) + second_high = high_pow[i]; + } + /* If second-best is within DTMF_DOMINANT_RATIO of best, ambiguous */ + if (second_low > 0.0f && max_low < DTMF_DOMINANT_RATIO * second_low) + goto no_tone; + if (second_high > 0.0f && max_high < DTMF_DOMINANT_RATIO * second_high) + goto no_tone; + } + + /* Guard (c): twist — power ratio must be within [1/TWIST_MAX, TWIST_MAX] */ + { + float ratio = max_low / max_high; + if (ratio > DTMF_TWIST_MAX || ratio < (1.0f / DTMF_TWIST_MAX)) + goto no_tone; + } + + /* --- Debounce state (static) --- */ + { + static char s_candidate = '\0'; + static int s_confirm_count = 0; + static int s_release_count = 0; + static bool s_armed = true; /* true = ready to report */ + + char sym = DTMF_MATRIX[best_low][best_high]; + if (sym == '\0') goto no_tone; /* 1633 Hz column — ignored */ + + /* Reset release counter: we have a tone */ + s_release_count = 0; + + if (!s_armed) { + /* Waiting for silence/release before accepting next press */ + return '\0'; + } + + if (sym == s_candidate) { + s_confirm_count++; + } else { + s_candidate = sym; + s_confirm_count = 1; + } + + if (s_confirm_count >= DTMF_CONFIRM_FRAMES) { + /* Confirmed — report and disarm until release */ + s_confirm_count = 0; + s_candidate = '\0'; + s_armed = false; + return sym; + } + return '\0'; + +no_tone: + /* No valid tone detected: advance release counter */ + ; /* label must precede a statement */ + s_confirm_count = 0; + s_candidate = '\0'; + if (!s_armed) { + s_release_count++; + if (s_release_count >= DTMF_RELEASE_FRAMES) { + s_armed = true; + s_release_count = 0; + } + } + return '\0'; + } +} + +/* ------------------------------------------------------------------------- + * Background capture task + * ---------------------------------------------------------------------- */ + +#define DTMF_TASK_STACK 4096 +#define DTMF_TASK_PRIO 3 +#define DTMF_FRAME_SIZE 320 + +static volatile bool s_armed_flag = false; /* true = task should process frames */ +static TaskHandle_t s_task_handle = NULL; + +static void dtmf_task(void *arg) +{ + (void)arg; + int16_t frame[DTMF_FRAME_SIZE]; + int64_t rms_sq; + + ESP_LOGI(TAG, "dtmf_task started"); + + /* Open the capture stream once and keep it open. + * Full-duplex: TX (speaker) stays active; RX is already enabled at boot. + * We pass generous max_ms / silence_ms since we never call capture_end + * while the call is in progress — dtmf_stop() just clears the armed flag. */ + if (audio_capture_begin(3600000, 3600000) != 0) { + ESP_LOGE(TAG, "dtmf_task: capture_begin failed — task exits"); + s_task_handle = NULL; + vTaskDelete(NULL); + return; + } + + for (;;) { + if (!s_armed_flag) { + /* Disarmed: drain frames slowly so the RX FIFO doesn't overflow */ + audio_capture_read_frame(frame, DTMF_FRAME_SIZE, &rms_sq); + vTaskDelay(pdMS_TO_TICKS(20)); + continue; + } + + int got = audio_capture_read_frame(frame, DTMF_FRAME_SIZE, &rms_sq); + if (got <= 0) continue; + + char sym = dtmf_detect_frame(frame, got); + if (sym == '\0') continue; + + ESP_LOGI(TAG, "DTMF detected: '%c'", sym); + if (sym >= '0' && sym <= '9') { + dialer_push_digit(sym - '0'); + } + /* '*' and '#' are logged only — no dialer push for now */ + } +} + +void dtmf_start(void) +{ + if (!s_task_handle) { + /* Create the task once */ + BaseType_t ok = xTaskCreatePinnedToCore( + dtmf_task, "dtmf", DTMF_TASK_STACK, NULL, DTMF_TASK_PRIO, + &s_task_handle, 1); + if (ok != pdPASS) { + ESP_LOGE(TAG, "dtmf_start: xTaskCreate failed"); + return; + } + } + s_armed_flag = true; + ESP_LOGI(TAG, "dtmf_start: DTMF detection armed"); +} + +void dtmf_stop(void) +{ + s_armed_flag = false; + ESP_LOGI(TAG, "dtmf_stop: DTMF detection disarmed"); +} + +#endif /* CONFIG_PLIP_DIAL_DTMF */ diff --git a/plip_voice/main/dtmf.h b/plip_voice/main/dtmf.h new file mode 100644 index 0000000..87bfffa --- /dev/null +++ b/plip_voice/main/dtmf.h @@ -0,0 +1,57 @@ +#pragma once +/* + * dtmf.h — DTMF (touch-tone) detector via Goertzel algorithm. + * + * API: + * dtmf_detect_frame() — pure signal processing, no I/O, testable standalone + * dtmf_start() / dtmf_stop() — arm/disarm the background capture task + * + * The capture task reads 20 ms frames from audio_capture_read_frame() and calls + * dialer_push_digit() for confirmed digit presses (digits 0-9 only). + * + * Guard: all declarations and the task body are compiled only when + * CONFIG_PLIP_DIAL_DTMF is set (see Kconfig.projbuild). + */ + +#include "sdkconfig.h" + +#if CONFIG_PLIP_DIAL_DTMF + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * Analyse one 20 ms frame (320 samples at 16 kHz) for a DTMF tone. + * + * Returns the detected character ('0'-'9', '*', '#') once per confirmed press, + * or '\0' when nothing is detected or debounce is still pending. + * + * Debounce rules (internal static state): + * - A symbol must appear in ≥ DTMF_CONFIRM_FRAMES consecutive frames to be + * reported. + * - After a report, at least DTMF_RELEASE_FRAMES of "no tone" must be seen + * before the same (or another) symbol can be reported again. + */ +char dtmf_detect_frame(const int16_t *mono, int n); + +/* + * Start the DTMF background task (created once; idempotent re-arm). + * The task reads microphone frames and pushes confirmed 0-9 digits to the + * dialer. Must be called after audio_init(). + */ +void dtmf_start(void); + +/* + * Disarm the DTMF task. The task suspends itself; the RX stream continues + * for the benefit of the voice capture path (full-duplex architecture). + */ +void dtmf_stop(void); + +#ifdef __cplusplus +} +#endif + +#endif /* CONFIG_PLIP_DIAL_DTMF */ diff --git a/plip_voice/main/es8388.c b/plip_voice/main/es8388.c index 37a2ebb..66ce795 100644 --- a/plip_voice/main/es8388.c +++ b/plip_voice/main/es8388.c @@ -151,7 +151,7 @@ esp_err_t es8388_init(void) * - ADCCONTROL4 (0x0C) = 0x0C: I2S Philips 16-bit word length * - ADCCONTROL5 (0x0D) = 0x02: ADCFsMode SINGLE SPEED RATIO=256 (16kHz@MCLK 4.096MHz) * - ADCCONTROL8/9 (0x10/0x11) = 0x00: ADC digital volume 0dB */ - if (i2c_write_reg(ES8388_ADC_CTL1, 0xBB) != ESP_OK) return ESP_FAIL; /* PGA +24dB L+R */ + if (i2c_write_reg(ES8388_ADC_CTL1, 0x44) != ESP_OK) return ESP_FAIL; /* MIC PGA +12dB L+R (was +24dB: too hot for a close handset mic → clipping/feedback) */ /* ADCCONTROL2 (0x0A): input select. The K50835F SLIC handset transmit audio is * wired to LIN2/RIN2 on this bench — PROVEN: speech captured (ACrms 196, crest 10.3) * on 0x50, vs DC-only floating offset on 0x00 (LIN1). */ @@ -224,22 +224,40 @@ esp_err_t es8388_init(void) es8388_read_reg(ES8388_CHIP_POWER, &chippower); ESP_LOGI(TAG, "ES8388 regs: CTL1=0x%02X ADCPWR=0x%02X ADCINSEL=0x%02X ADCCTL3=0x%02X DACCTL21=0x%02X CHIPPOWER=0x%02X", ctl1, adcpwr, adcinsel, adcctl3, dacctl21, chippower); - ESP_LOGI(TAG, "ES8388 init OK — PA enabled, DAC @ 0dB, ADC PGA +24dB, input=LIN1/RIN1 (LINE IN), DACCTL21=0x80"); + ESP_LOGI(TAG, "ES8388 init OK — PA enabled, DAC @ 0dB, ADC PGA +12dB, input=LIN2/RIN2 (SLIC handset), DACCTL21=0x80"); return ESP_OK; } esp_err_t es8388_set_volume(uint8_t vol) { - /* Map 0..100 to 0x00 (0dB) .. 0x24 (mute); register is attenuation. - * Volume registers: DACCONTROL24 (0x2E) = OUT1L, DACCONTROL25 (0x2F) = OUT1R, - * DACCONTROL26 (0x30) = OUT2L, DACCONTROL27 (0x31) = OUT2R. + /* ES8388 OUTx volume registers are GAIN, not attenuation: 0x00 = -45 dB + * (min) .. 0x21 = 0 dB (max); higher value = louder (>0x21 = mute/reserved). + * Map 0..100 → 0x00..0x21. (The previous code inverted this, so vol=100 + * produced 0x00 = quietest — confirmed at the bench.) + * DACCONTROL24 (0x2E)=OUT1L, 25 (0x2F)=OUT1R, 26 (0x30)=OUT2L, 27 (0x31)=OUT2R. * DACCONTROL21 (0x2B) is the ADC/DAC LRCK sync register — DO NOT touch here. */ - uint8_t reg_val = (uint8_t)((100 - (int)vol) * 0x24 / 100); - ESP_LOGI(TAG, "set_volume: %d%% -> reg=0x%02X", vol, reg_val); + if (vol > 100) vol = 100; + uint8_t reg_val = (uint8_t)((int)vol * 0x21 / 100); + if (reg_val > 0x21) reg_val = 0x21; + ESP_LOGI(TAG, "set_volume: %d%% -> reg=0x%02X (0x21=max,0dB)", vol, reg_val); esp_err_t r = ESP_OK; r |= i2c_write_reg(ES8388_DAC_CTL24, reg_val); /* OUT1 L volume */ r |= i2c_write_reg(ES8388_DAC_CTL25, reg_val); /* OUT1 R volume */ r |= i2c_write_reg(ES8388_DAC_CTL26, reg_val); /* OUT2 L volume */ + r |= i2c_write_reg(0x31, reg_val); /* OUT2 R volume (DACCONTROL27) */ + return r; +} + +esp_err_t es8388_set_dac_volume(uint8_t atten) +{ + /* DACCONTROL4 (0x04) = LDACVOL, DACCONTROL5 (0x05) = RDACVOL: DIGITAL DAC + * volume, applied BEFORE the analog output stages. 0x00 = 0 dB, each step + * = -0.5 dB, up to 0xC0 = -96 dB (mute). Lowering this gives analog + * headroom while keeping the output-stage volume (es8388_set_volume) high. */ + if (atten > 0xC0) atten = 0xC0; + ESP_LOGI(TAG, "set_dac_volume: atten=0x%02X (-%.1f dB)", atten, atten * 0.5f); + esp_err_t r = i2c_write_reg(ES8388_DAC_CTL4, atten); + r |= i2c_write_reg(ES8388_DAC_CTL5, atten); return r; } diff --git a/plip_voice/main/es8388.h b/plip_voice/main/es8388.h index cb25aad..b321c69 100644 --- a/plip_voice/main/es8388.h +++ b/plip_voice/main/es8388.h @@ -29,6 +29,11 @@ esp_err_t es8388_init(void); * OUT1 (headphone) + OUT2 (speaker) attenuation registers. */ esp_err_t es8388_set_volume(uint8_t vol); +/* Set the DIGITAL DAC volume (DACCONTROL4/5), applied before the analog output + * stages. atten: 0 = 0 dB (full), each step -0.5 dB, 0xC0 = mute. Lowering it + * gives analog headroom while keeping es8388_set_volume() high. */ +esp_err_t es8388_set_dac_volume(uint8_t atten); + /* Mute / unmute DAC output. */ esp_err_t es8388_mute(bool mute); diff --git a/plip_voice/main/net.c b/plip_voice/main/net.c index 3cdc570..2d7414d 100644 --- a/plip_voice/main/net.c +++ b/plip_voice/main/net.c @@ -21,7 +21,6 @@ #include #include #include -#include #include #include "audio.h" @@ -37,9 +36,9 @@ #include "esp_log.h" #include "esp_netif.h" #include "esp_spiffs.h" +#include "esp_timer.h" #include "esp_wifi.h" #include "freertos/FreeRTOS.h" -#include "freertos/event_groups.h" #include "freertos/task.h" #include "nvs.h" #include "nvs_flash.h" @@ -467,6 +466,153 @@ static esp_err_t handle_debug_slic(httpd_req_t *req) return send_json(req, "200 OK", buf); } +/* ── GET /debug/hookmon (fast-sample the hook GPIO, log transitions) ───────── + * Samples CONFIG_PLIP_HOOK_GPIO every 2 ms for ~6 s and records every level + * transition with a timestamp. Catches both slow hook toggles AND fast rotary + * pulses (~60-100 ms) that the 0.7 s HTTP poll of /debug/slic cannot see. + * The user performs the physical action (lift/hang/dial) during the window. */ + +#define HOOKMON_SAMPLE_MS 2 +#define HOOKMON_WINDOW_MS 6000 +#define HOOKMON_MAX_EDGES 64 + +static esp_err_t handle_debug_hookmon(httpd_req_t *req) +{ + const int gpio = CONFIG_PLIP_HOOK_GPIO; + int last = gpio_get_level(gpio); + const int initial = last; + + int64_t t0 = esp_timer_get_time(); + int edges_t[HOOKMON_MAX_EDGES]; + int edges_l[HOOKMON_MAX_EDGES]; + int n_edges = 0; + int lo = last, hi = last; /* track min/max seen */ + + ESP_LOGI(TAG, "hookmon: start, gpio=%d initial=%d (sample %dms / window %dms)", + gpio, initial, HOOKMON_SAMPLE_MS, HOOKMON_WINDOW_MS); + + for (;;) { + int64_t now = esp_timer_get_time(); + int dt = (int)((now - t0) / 1000); + if (dt >= HOOKMON_WINDOW_MS) break; + + int lvl = gpio_get_level(gpio); + if (lvl < lo) lo = lvl; + if (lvl > hi) hi = lvl; + if (lvl != last) { + if (n_edges < HOOKMON_MAX_EDGES) { + edges_t[n_edges] = dt; + edges_l[n_edges] = lvl; + n_edges++; + } + last = lvl; + } + vTaskDelay(pdMS_TO_TICKS(HOOKMON_SAMPLE_MS)); + } + + /* Build JSON: { gpio, initial, final, lo, hi, edges:[{t,l},...], count } */ + char buf[1024]; + int off = 0; + off += snprintf(buf + off, sizeof(buf) - off, + "{\"gpio\":%d,\"initial\":%d,\"final\":%d,\"lo\":%d,\"hi\":%d," + "\"count\":%d,\"edges\":[", + gpio, initial, last, lo, hi, n_edges); + for (int i = 0; i < n_edges && off < (int)sizeof(buf) - 32; i++) { + off += snprintf(buf + off, sizeof(buf) - off, "%s{\"t\":%d,\"l\":%d}", + i ? "," : "", edges_t[i], edges_l[i]); + } + off += snprintf(buf + off, sizeof(buf) - off, "]}"); + + ESP_LOGI(TAG, "hookmon: done, %d edges, lo=%d hi=%d", n_edges, lo, hi); + return send_json(req, "200 OK", buf); +} + +/* ── GET /debug/dacvol?a=N (set ES8388 DIGITAL DAC volume, live tuning) ─────── */ + +static esp_err_t handle_debug_dacvol(httpd_req_t *req) +{ + char query[32] = {0}; + httpd_req_get_url_query_str(req, query, sizeof(query)); + char astr[8] = {0}; + if (httpd_query_key_value(query, "a", astr, sizeof(astr)) != ESP_OK) { + return send_json(req, "400 Bad Request", "{\"error\":\"missing a param (atten steps, 0=0dB)\"}"); + } + int a = atoi(astr); + if (a < 0) a = 0; + if (a > 192) a = 192; + esp_err_t r = es8388_set_dac_volume((uint8_t)a); + char resp[80]; + snprintf(resp, sizeof(resp), "{\"ok\":%s,\"atten_steps\":%d,\"db\":-%.1f}", + (r == ESP_OK) ? "true" : "false", a, a * 0.5); + ESP_LOGI(TAG, "debug/dacvol: atten=%d (-%.1f dB)", a, a * 0.5); + return send_json(req, "200 OK", resp); +} + +/* ── GET /debug/offhook?on=1 (force hook state, bypass flaky contact) ───────── */ + +static esp_err_t handle_debug_offhook(httpd_req_t *req) +{ + char query[24] = {0}; + httpd_req_get_url_query_str(req, query, sizeof(query)); + char on[4] = {0}; + int v = 1; /* default: force off-hook */ + if (httpd_query_key_value(query, "on", on, sizeof(on)) == ESP_OK) v = atoi(on); + phone_force_offhook(v != 0); + char resp[64]; + snprintf(resp, sizeof(resp), "{\"ok\":true,\"forced_offhook\":%s}", v ? "true" : "false"); + ESP_LOGI(TAG, "debug/offhook: forced %s", v ? "off-hook" : "on-hook"); + return send_json(req, "200 OK", resp); +} + +/* ── GET /debug/getfile?path=/x.wav (read a SPIFFS file back for diagnosis) ── */ + +static esp_err_t handle_debug_getfile(httpd_req_t *req) +{ + char query[160] = {0}; + httpd_req_get_url_query_str(req, query, sizeof(query)); + char fpath[96] = {0}; + if (httpd_query_key_value(query, "path", fpath, sizeof(fpath)) != ESP_OK) { + return send_json(req, "400 Bad Request", "{\"error\":\"missing path param\"}"); + } + ensure_spiffs(); + char full[160]; + if (fpath[0] == '/') snprintf(full, sizeof(full), "%s%s", SPIFFS_BASE, fpath); + else snprintf(full, sizeof(full), "%s/%s", SPIFFS_BASE, fpath); + + FILE *f = fopen(full, "rb"); + if (!f) return send_json(req, "404 Not Found", "{\"error\":\"not found\"}"); + + httpd_resp_set_type(req, "application/octet-stream"); + char chunk[1024]; + size_t r; + while ((r = fread(chunk, 1, sizeof(chunk), f)) > 0) { + if (httpd_resp_send_chunk(req, chunk, r) != ESP_OK) { fclose(f); return ESP_FAIL; } + } + fclose(f); + httpd_resp_send_chunk(req, NULL, 0); /* terminate */ + return ESP_OK; +} + +/* ── GET /debug/vol?v=N (set ES8388 output volume 0..100, live tuning) ─────── */ + +static esp_err_t handle_debug_vol(httpd_req_t *req) +{ + char query[32] = {0}; + httpd_req_get_url_query_str(req, query, sizeof(query)); + char vstr[8] = {0}; + if (httpd_query_key_value(query, "v", vstr, sizeof(vstr)) != ESP_OK) { + return send_json(req, "400 Bad Request", "{\"error\":\"missing v param (0..100)\"}"); + } + int v = atoi(vstr); + if (v < 0) v = 0; + if (v > 100) v = 100; + esp_err_t r = es8388_set_volume((uint8_t)v); + char resp[64]; + snprintf(resp, sizeof(resp), "{\"ok\":%s,\"volume\":%d}", (r == ESP_OK) ? "true" : "false", v); + ESP_LOGI(TAG, "debug/vol: set volume=%d (%s)", v, (r == ESP_OK) ? "ok" : "err"); + return send_json(req, "200 OK", resp); +} + /* ── GET /debug/dial?number=NNNN (push digits into the dialer) ───────────── */ static esp_err_t handle_debug_dial(httpd_req_t *req) @@ -565,7 +711,7 @@ esp_err_t net_init(void) /* Start HTTP server. */ httpd_config_t hcfg = HTTPD_DEFAULT_CONFIG(); hcfg.server_port = 80; - hcfg.max_uri_handlers = 17; + hcfg.max_uri_handlers = 16; hcfg.stack_size = 8192; esp_err_t ret = httpd_start(&s_httpd, &hcfg); @@ -602,6 +748,22 @@ esp_err_t net_init(void) .uri = "/debug/dial", .method = HTTP_GET, .handler = handle_debug_dial, .user_ctx = NULL, }; + static const httpd_uri_t uri_debug_vol = { + .uri = "/debug/vol", .method = HTTP_GET, + .handler = handle_debug_vol, .user_ctx = NULL, + }; + static const httpd_uri_t uri_debug_getfile = { + .uri = "/debug/getfile", .method = HTTP_GET, + .handler = handle_debug_getfile, .user_ctx = NULL, + }; + static const httpd_uri_t uri_debug_dacvol = { + .uri = "/debug/dacvol", .method = HTTP_GET, + .handler = handle_debug_dacvol, .user_ctx = NULL, + }; + static const httpd_uri_t uri_debug_offhook = { + .uri = "/debug/offhook", .method = HTTP_GET, + .handler = handle_debug_offhook, .user_ctx = NULL, + }; static const httpd_uri_t uri_debug_ring = { .uri = "/debug/ring", .method = HTTP_GET, .handler = handle_debug_ring, .user_ctx = NULL, @@ -614,6 +776,10 @@ esp_err_t net_init(void) .uri = "/debug/slic", .method = HTTP_GET, .handler = handle_debug_slic, .user_ctx = NULL, }; + static const httpd_uri_t uri_debug_hookmon = { + .uri = "/debug/hookmon", .method = HTTP_GET, + .handler = handle_debug_hookmon, .user_ctx = NULL, + }; httpd_register_uri_handler(s_httpd, &uri_status); httpd_register_uri_handler(s_httpd, &uri_scenario); httpd_register_uri_handler(s_httpd, &uri_file); @@ -621,9 +787,14 @@ esp_err_t net_init(void) httpd_register_uri_handler(s_httpd, &uri_capture); httpd_register_uri_handler(s_httpd, &uri_debug_regs); httpd_register_uri_handler(s_httpd, &uri_debug_dial); + httpd_register_uri_handler(s_httpd, &uri_debug_vol); + httpd_register_uri_handler(s_httpd, &uri_debug_getfile); + httpd_register_uri_handler(s_httpd, &uri_debug_dacvol); + httpd_register_uri_handler(s_httpd, &uri_debug_offhook); httpd_register_uri_handler(s_httpd, &uri_debug_ring); httpd_register_uri_handler(s_httpd, &uri_debug_ringstop); httpd_register_uri_handler(s_httpd, &uri_debug_slic); + httpd_register_uri_handler(s_httpd, &uri_debug_hookmon); ESP_LOGI(TAG, "httpd up on :80 (GET /status, GET /debug/regs, GET /debug/dial, GET /debug/ring, GET /debug/ringstop, GET /debug/slic, POST /game/scenario, POST /game/file, POST /game/cmd, POST /voice/capture)"); return ESP_OK; diff --git a/plip_voice/main/phone.c b/plip_voice/main/phone.c index eee7653..9adde18 100644 --- a/plip_voice/main/phone.c +++ b/plip_voice/main/phone.c @@ -34,13 +34,35 @@ #define TAG "phone" #define DEBOUNCE_MS 30 -#define HANGUP_THRESHOLD_MS 500 /* open > this → real hangup, not a pulse */ +#define HANGUP_THRESHOLD_MS 1500 /* open > this → real hangup, not a pulse. + * Raised from 500 ms: the A1S cradle contact + * is marginal and produces ~500 ms spurious + * opens that were resetting the call before + * the NPC greeting. A real hangup holds the + * line open indefinitely, so it still fires + * (~1.5 s later). Rotary pulses (~60 ms) and + * closed inter-digit gaps are unaffected. */ #define TASK_STACK 4096 #define TASK_PRIO 5 +#define RESYNC_STABLE_MS 600 /* if the raw hook level stably disagrees with + * s_offhook for this long (no pulse), the edge + * detection missed/flapped a transition → + * self-correct s_offhook to the physical level */ + +/* Rotary pulse hardening (CONFIG_PLIP_DIAL_PULSE) */ +#define PULSE_MIN_WIDTH_MS 20 /* ignore opens shorter than this (glitch filter) */ + /* real rotary pulses are ≥ 40 ms open */ +#define HANGUP_VERIFY_COUNT 5 /* re-read GPIO this many times before declaring */ + /* hangup (guards against marginal cradle contact) */ +#define HANGUP_VERIFY_STEP_MS 20 /* delay between verification reads (ms); */ + /* total span = COUNT × STEP = 100 ms */ static volatile bool s_edge_pending = false; static volatile bool s_ringing = false; static volatile bool s_offhook = false; /* true = handset picked up */ +static volatile bool s_hook_override = false; /* when true, ignore the physical hook + * and hold s_offhook at the forced value + * (debug: decouple from a flaky contact) */ /* IRAM_ATTR: ISR must live in IRAM on original ESP32. */ static void IRAM_ATTR on_hook_isr(void *arg) @@ -114,6 +136,7 @@ static void phone_task(void *arg) /* Read and report initial level so master state machine is in sync. */ int last_level = gpio_get_level(hook_gpio); s_offhook = (last_level == HOOK_OFFHOOK_LEVEL); + int resync_ms = 0; /* accumulates while raw level stably disagrees with s_offhook */ ESP_LOGI(TAG, "phone task ready, hook GPIO=%d level=%d active_%s (%s)", hook_gpio, last_level, (HOOK_OFFHOOK_LEVEL == 1) ? "high" : "low", @@ -140,6 +163,15 @@ static void phone_task(void *arg) #endif for (;;) { + /* Debug override: hold s_offhook at the forced value, ignore the + * physical hook entirely (decouples the voice loop from a flaky + * cradle contact). Set via phone_force_offhook() / GET /debug/offhook. */ + if (s_hook_override) { + s_edge_pending = false; + resync_ms = 0; + vTaskDelay(pdMS_TO_TICKS(20)); + continue; + } if (s_edge_pending) { s_edge_pending = false; @@ -163,8 +195,14 @@ static void phone_task(void *arg) last_close_us = esp_timer_get_time(); int64_t open_dur_ms = (last_close_us - pulse_open_us) / 1000; - if (in_pulse && open_dur_ms < HANGUP_THRESHOLD_MS) { - /* Short open: count as a rotary pulse */ + if (in_pulse && open_dur_ms < PULSE_MIN_WIDTH_MS) { + /* Glitch filter: open was too brief to be a real pulse. + * Ignore — do not count as pulse and do not declare hangup. */ + in_pulse = false; + ESP_LOGD(TAG, "glitch ignored (%"PRId64"ms < %dms)", + open_dur_ms, PULSE_MIN_WIDTH_MS); + } else if (in_pulse && open_dur_ms < HANGUP_THRESHOLD_MS) { + /* Valid rotary pulse duration: count it */ pulse_count++; in_pulse = false; ESP_LOGD(TAG, "pulse %d (open %"PRId64"ms)", pulse_count, open_dur_ms); @@ -231,13 +269,37 @@ static void phone_task(void *arg) } /* Also detect prolonged open (hangup) even if no more edges arrive. - * Active-HIGH: hangup = level stays at HOOK_PULSE_OPEN (LOW) > 500 ms. */ + * Active-HIGH: hangup = level stays at HOOK_PULSE_OPEN (LOW) > 500 ms. + * + * Hardening: re-sample the GPIO HANGUP_VERIFY_COUNT times with + * HANGUP_VERIFY_STEP_MS gaps. Only declare hangup if every sample + * confirms the open state — guards against a marginal cradle contact + * that briefly dips below HANGUP_THRESHOLD_MS and then bounces back. */ if (s_offhook && in_pulse && pulse_open_us > 0) { int64_t open_ms = (esp_timer_get_time() - pulse_open_us) / 1000; if (open_ms >= HANGUP_THRESHOLD_MS) { - int level_now = gpio_get_level(hook_gpio); - if (level_now == HOOK_PULSE_OPEN) { - ESP_LOGI(TAG, "on-hook (prolonged open %"PRId64"ms) detected", open_ms); + /* Multi-sample verification */ + bool confirmed = true; + for (int v = 0; v < HANGUP_VERIFY_COUNT; v++) { + vTaskDelay(pdMS_TO_TICKS(HANGUP_VERIFY_STEP_MS)); + if (gpio_get_level(hook_gpio) != HOOK_PULSE_OPEN) { + /* Line recovered during verification — not a real hangup */ + confirmed = false; + ESP_LOGD(TAG, "hangup verify failed at sample %d — bounce", v); + /* Treat the level as if the line just closed */ + last_level = HOOK_PULSE_CLOSED; + last_close_us = esp_timer_get_time(); + /* The open was shorter than HANGUP_THRESHOLD_MS in effect, + * but we already passed the threshold — do not count as + * a pulse (it's the same ambiguous situation we reject + * elsewhere). Simply clear in_pulse. */ + in_pulse = false; + break; + } + } + if (confirmed) { + ESP_LOGI(TAG, "on-hook (prolonged open %"PRId64"ms, verified ×%d) detected", + open_ms, HANGUP_VERIFY_COUNT); last_level = HOOK_PULSE_OPEN; pulse_count = 0; in_pulse = false; @@ -245,20 +307,41 @@ static void phone_task(void *arg) audio_stop(); audio_pa_set(false); report_offhook(false); - } else { - /* GPIO returned to off-hook level but ISR missed it */ - last_level = HOOK_PULSE_CLOSED; - last_close_us = esp_timer_get_time(); - if ((esp_timer_get_time() - pulse_open_us) / 1000 < HANGUP_THRESHOLD_MS) { - pulse_count++; - } - in_pulse = false; } } } poll_sleep: #endif + /* ── Self-healing resync ────────────────────────────────────────────── + * The marginal A1S cradle contact occasionally makes the edge detection + * miss/flap a transition, leaving s_offhook stuck (e.g. firmware thinks + * on-hook while the handset is physically off-hook → the call never + * starts). If the raw level stably disagrees with s_offhook for + * RESYNC_STABLE_MS — and we are NOT mid rotary-pulse — trust the + * physical level and correct s_offhook, firing the transition. */ + { + bool pulse_active = false; +#if CONFIG_PLIP_DIAL_PULSE + pulse_active = in_pulse; +#endif + bool phys_off = (gpio_get_level(hook_gpio) == HOOK_OFFHOOK_LEVEL); + if (!pulse_active && phys_off != s_offhook) { + resync_ms += 10; + if (resync_ms >= RESYNC_STABLE_MS) { + ESP_LOGW(TAG, "hook resync: physical=%s but s_offhook=%d — correcting", + phys_off ? "off-hook" : "on-hook", (int)s_offhook); + s_offhook = phys_off; + last_level = phys_off ? HOOK_OFFHOOK_LEVEL : !HOOK_OFFHOOK_LEVEL; + audio_pa_set(phys_off); + if (!phys_off) audio_stop(); + report_offhook(phys_off); + resync_ms = 0; + } + } else { + resync_ms = 0; + } + } vTaskDelay(pdMS_TO_TICKS(10)); } } @@ -275,3 +358,22 @@ bool phone_is_offhook(void) { return s_offhook; } + +void phone_force_offhook(bool off) +{ + /* Debug: override the physical hook. off=true → simulate pickup (DIALTONE), + * off=false → simulate hangup (IDLE). Stays in effect until reboot. */ + s_hook_override = true; + if (off == s_offhook) { + ESP_LOGI(TAG, "force_offhook: already %s (override on)", off ? "off-hook" : "on-hook"); + return; + } + s_offhook = off; + ESP_LOGI(TAG, "force_offhook: %s (override)", off ? "off-hook" : "on-hook"); + audio_pa_set(off); + if (!off) { + audio_stop(); + phone_ring_stop(); + } + report_offhook(off); +} diff --git a/plip_voice/main/phone.h b/plip_voice/main/phone.h index a9b1795..7d49bbf 100644 --- a/plip_voice/main/phone.h +++ b/plip_voice/main/phone.h @@ -31,6 +31,11 @@ void phone_ring_stop(void); * Safe to call from any task; backed by a volatile flag updated in phone_task. */ bool phone_is_offhook(void); +/* Debug: force the hook state, overriding the physical contact until reboot. + * off=true simulates pickup (→ DIALTONE), off=false simulates hangup (→ IDLE). + * Lets the voice loop be validated independently of a flaky cradle contact. */ +void phone_force_offhook(bool off); + #ifdef __cplusplus } #endif diff --git a/plip_voice/main/slic.c b/plip_voice/main/slic.c index 0ff4c67..e6391ef 100644 --- a/plip_voice/main/slic.c +++ b/plip_voice/main/slic.c @@ -20,33 +20,68 @@ #define TAG "slic" -/* K50835F SHK is OPEN-COLLECTOR ACTIVE-LOW: the SLIC pulls SHK LOW when the - * loop is closed (off-hook) and releases it (pull-up → HIGH) when on-hook. - * So off-hook = LOW. (A252 used active-high via an external inverter; the A1S - * wires SHK straight to the GPIO, so the raw polarity is active-low.) */ -#define SLIC_SHK_OFFHOOK_LEVEL 0 +/* Empirically on THIS A1S+SLIC unit, SHK is ACTIVE-HIGH: off-hook (loop closed) + * drives SHK HIGH, on-hook drives it LOW. Confirmed at the bench via + * /debug/hookmon: on-hook→0, pickup-during-ring→0→1 transition, off-hook→1. + * (The A252 reference was active-low; the polarity is inverted here, so the + * raw straight-wired GPIO reads off-hook = HIGH.) */ +#define SLIC_SHK_OFFHOOK_LEVEL 1 static volatile bool s_ringing = false; static TaskHandle_t s_ring_task = NULL; -/* ── FR toggle task (ring drive at ~25 Hz) ────────────────────────────────── */ +/* France Télécom ring cadence: 1.5 s burst ON, 3.5 s silent pause, repeating. + * (Single-ring cadence — distinct from the UK double-ring or US 2 s/4 s.) + * The bell is driven during the burst by toggling FR at ~25 Hz with RM HIGH; + * during the pause RM and FR are held low so the bell is silent. */ +#define RING_FR_TOGGLE_MS 20 /* 20 ms half-period → ~25 Hz bell drive */ +#define RING_BURST_MS 1500 /* FT: 1.5 s of ringing */ +#define RING_PAUSE_MS 3500 /* FT: 3.5 s of silence */ + +/* ── FR toggle + cadence task ─────────────────────────────────────────────── */ static void slic_ring_task(void *arg) { (void)arg; bool fr_state = false; + bool in_burst = true; /* start each ring with a burst */ + int phase_ms = 0; /* elapsed ms in the current burst/pause phase */ for (;;) { if (s_ringing) { - fr_state = !fr_state; - gpio_set_level(PLIP_SLIC_FR, fr_state ? 1 : 0); - ESP_LOGV(TAG, "FR=%d", fr_state ? 1 : 0); + if (in_burst) { + /* Ring burst: RM HIGH, FR toggling at ~25 Hz to swing the bell */ + gpio_set_level(PLIP_SLIC_RM, 1); + fr_state = !fr_state; + gpio_set_level(PLIP_SLIC_FR, fr_state ? 1 : 0); + phase_ms += RING_FR_TOGGLE_MS; + if (phase_ms >= RING_BURST_MS) { + /* End of burst → enter silent pause */ + in_burst = false; + phase_ms = 0; + fr_state = false; + gpio_set_level(PLIP_SLIC_RM, 0); + gpio_set_level(PLIP_SLIC_FR, 0); + ESP_LOGV(TAG, "ring: burst end -> pause"); + } + } else { + /* Silent pause: RM/FR stay low */ + phase_ms += RING_FR_TOGGLE_MS; + if (phase_ms >= RING_PAUSE_MS) { + in_burst = true; + phase_ms = 0; + ESP_LOGV(TAG, "ring: pause end -> burst"); + } + } } else { - /* Suspended — keep FR low while idle */ + /* Idle — keep RM/FR low and reset the cadence for the next ring */ + gpio_set_level(PLIP_SLIC_RM, 0); gpio_set_level(PLIP_SLIC_FR, 0); fr_state = false; + in_burst = true; + phase_ms = 0; } - vTaskDelay(pdMS_TO_TICKS(20)); /* 20 ms → ~25 Hz */ + vTaskDelay(pdMS_TO_TICKS(RING_FR_TOGGLE_MS)); } } @@ -54,10 +89,12 @@ static void slic_ring_task(void *arg) esp_err_t slic_init(void) { - /* RM: Ring Mode output, init LOW */ + /* RM: Ring Mode output, init LOW. + * INPUT_OUTPUT (not plain OUTPUT) so gpio_get_level() reads back the real + * pad level — plain OUTPUT disables the input buffer and always reads 0. */ gpio_config_t rm_cfg = { .pin_bit_mask = (1ULL << PLIP_SLIC_RM), - .mode = GPIO_MODE_OUTPUT, + .mode = GPIO_MODE_INPUT_OUTPUT, .pull_up_en = GPIO_PULLUP_DISABLE, .pull_down_en = GPIO_PULLDOWN_DISABLE, .intr_type = GPIO_INTR_DISABLE, @@ -66,10 +103,10 @@ esp_err_t slic_init(void) if (ret != ESP_OK) return ret; gpio_set_level(PLIP_SLIC_RM, 0); - /* FR: Forward/Reverse output, init LOW */ + /* FR: Forward/Reverse output, init LOW (INPUT_OUTPUT for readback, see RM). */ gpio_config_t fr_cfg = { .pin_bit_mask = (1ULL << PLIP_SLIC_FR), - .mode = GPIO_MODE_OUTPUT, + .mode = GPIO_MODE_INPUT_OUTPUT, .pull_up_en = GPIO_PULLUP_DISABLE, .pull_down_en = GPIO_PULLDOWN_DISABLE, .intr_type = GPIO_INTR_DISABLE, @@ -92,7 +129,9 @@ esp_err_t slic_init(void) /* PD: Power Down — EXACT A252-proven sequence: open-drain output, HIGH = released * = SLIC active (setPowerDown(false) in Ks0835SlicController). This is the config * the working Arduino slic-phone project uses on the same chip. */ - ret = gpio_set_direction(PLIP_SLIC_PD, GPIO_MODE_OUTPUT_OD); + /* INPUT_OUTPUT_OD (not plain OUTPUT_OD) so gpio_get_level() reads the real + * pad level — OUTPUT_OD also disables the input buffer and would read 0. */ + ret = gpio_set_direction(PLIP_SLIC_PD, GPIO_MODE_INPUT_OUTPUT_OD); if (ret != ESP_OK) return ret; ret = gpio_set_level(PLIP_SLIC_PD, 1); /* open-drain released HIGH = active (A252-proven) */ if (ret != ESP_OK) return ret; @@ -123,8 +162,10 @@ bool slic_is_offhook(void) void slic_ring_start(void) { if (s_ringing) return; - ESP_LOGI(TAG, "ring start: RM=HIGH, FR toggling at 25 Hz"); - gpio_set_level(PLIP_SLIC_RM, 1); + ESP_LOGI(TAG, "ring start: France Télécom cadence %d ms ON / %d ms OFF", + RING_BURST_MS, RING_PAUSE_MS); + /* The cadence task drives RM/FR; it starts on a burst (in_burst reset in + * the idle branch). Just arm it here. */ s_ringing = true; } diff --git a/plip_voice/main/turn_client.c b/plip_voice/main/turn_client.c index 724f32b..0be6ab0 100644 --- a/plip_voice/main/turn_client.c +++ b/plip_voice/main/turn_client.c @@ -1,11 +1,14 @@ /* - * turn_client.c — NPC gateway client for /v1/voice/turn. + * 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" @@ -26,8 +29,17 @@ /* 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 30000 +#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, @@ -129,3 +141,200 @@ bool turn_client_greeting(const char *session_id, 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): + * + * --\r\n + * Content-Disposition: form-data; name="session_id"\r\n\r\n + * \r\n + * --\r\n + * Content-Disposition: form-data; name="number"\r\n\r\n + * \r\n + * --\r\n + * Content-Disposition: form-data; name="audio"; filename="rec.wav"\r\n + * Content-Type: audio/wav\r\n\r\n + * + * \r\n----\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; +} diff --git a/plip_voice/main/turn_client.h b/plip_voice/main/turn_client.h index cd27b9e..ca86736 100644 --- a/plip_voice/main/turn_client.h +++ b/plip_voice/main/turn_client.h @@ -3,9 +3,12 @@ * 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". + * Stage 3: reply fetch via /v1/voice/reply (multipart/form-data with captured WAV). */ #include +#include +#include +#include "esp_err.h" #ifdef __cplusplus extern "C" { @@ -30,6 +33,34 @@ bool turn_client_greeting(const char *session_id, const char *number, const char *out_path); +/* + * turn_client_reply — POST /v1/voice/reply as multipart/form-data. + * + * Endpoint: CONFIG_PLIP_GATEWAY_URL/v1/voice/reply + * Method: POST multipart/form-data + * Fields: session_id (text), number (text), audio (file, "rec.wav", audio/wav) + * Bearer: CONFIG_PLIP_GATEWAY_TOKEN (skipped if empty) + * + * The function builds the multipart body in three segments: + * preamble = boundary + session_id part + boundary + number part + + * boundary + audio file header + * wav data = wav bytes (wav_len bytes, sent in 4 KB chunks) + * epilogue = CRLF + closing boundary + * + * Content-Length = len(preamble) + wav_len + len(epilogue) + * The response body (WAV 16 kHz) is streamed into out_path. + * + * Response headers X-Zacus-Heard and X-Zacus-Said are logged at INFO level. + * + * Returns ESP_OK if HTTP 200 and a valid WAV (> 44 bytes) was written. + * On any failure: logs a warning and returns an ESP error code. + */ +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); + #ifdef __cplusplus } #endif diff --git a/plip_voice/sdkconfig.defaults b/plip_voice/sdkconfig.defaults index 9bc14b6..b6f354d 100644 --- a/plip_voice/sdkconfig.defaults +++ b/plip_voice/sdkconfig.defaults @@ -37,7 +37,7 @@ CONFIG_FREERTOS_IDLE_TASK_STACKSIZE=2048 # SLIC hook GPIO — SHK on GPIO23 (A1S KEY4, active-HIGH: HIGH = off-hook) CONFIG_PLIP_HOOK_GPIO=23 -CONFIG_PLIP_HOOK_ACTIVE_HIGH=n +CONFIG_PLIP_HOOK_ACTIVE_HIGH=y # WiFi credentials — set via `idf.py menuconfig` (PLIP Voice Configuration) # or override locally in sdkconfig (NOT committed).