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
This commit was merged in pull request #25.
This commit is contained in:
@@ -10,6 +10,7 @@ idf_component_register(
|
||||
"tones.c"
|
||||
"dialer.c"
|
||||
"conversation.c"
|
||||
"dtmf.c"
|
||||
"turn_client.c"
|
||||
"slic.c"
|
||||
INCLUDE_DIRS "."
|
||||
|
||||
@@ -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 <token>" 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
|
||||
|
||||
+74
-62
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#if CONFIG_PLIP_VOICE_REPLY
|
||||
/* Capture buffer sizing.
|
||||
* PSRAM target: 8 s of 16kHz mono S16 + 44-byte WAV header.
|
||||
* 8 s × 16000 samples/s × 2 bytes = 256000 bytes + 44 = 256044 → round to 256 KB.
|
||||
* Fallback (internal heap, 4 s max):
|
||||
* 4 s × 16000 × 2 + 44 = 128044 → round to 128 KB.
|
||||
*/
|
||||
#define CAPTURE_MAX_PSRAM (256 * 1024)
|
||||
#define CAPTURE_MAX_IRAM (128 * 1024)
|
||||
#define CAPTURE_MAX_MS_PSRAM 8000
|
||||
#define CAPTURE_MAX_MS_IRAM 4000
|
||||
#define CAPTURE_SILENCE_MS 800
|
||||
#define REPLY_POLL_MS 200 /* interval for checking hook during playback */
|
||||
#define REPLY_PLAYBACK_EXTRA_MS 500 /* safety margin added to computed WAV duration */
|
||||
#define BETWEEN_TURNS_MS 300 /* short pause between capture rounds */
|
||||
#endif /* CONFIG_PLIP_VOICE_REPLY */
|
||||
|
||||
#define TAG "conversation"
|
||||
|
||||
/* Duration of ringback before picking up and fetching the greeting */
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <math.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#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 */
|
||||
@@ -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 <stdint.h>
|
||||
|
||||
#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 */
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
+174
-3
@@ -21,7 +21,6 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <sys/stat.h>
|
||||
#include <errno.h>
|
||||
|
||||
#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;
|
||||
|
||||
+117
-15
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+59
-18
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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):
|
||||
*
|
||||
* --<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;
|
||||
}
|
||||
|
||||
@@ -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 <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#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
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user