Compare commits

...

3 Commits

Author SHA1 Message Date
clement 54022ed6cc fix(plip): VAD calib for quiet voice + ring stop
CI / platformio (pull_request) Failing after 9m38s
CI / platformio (push) Failing after 13m49s
Recalibrate the capture VAD to the quiet handset voice: onset 1.4%->0.7%
FS (was never triggering -> 'no sustained voice'), silence 0.6%->0.34%
FS and end-of-speech window 600->900 ms so a whole sentence is held
instead of cut mid-phrase. Also force slic_ring_stop() on incoming
pickup so the physical bell always stops when answered (phone.c/slic.c
s_ringing could desync, leaving the bell ringing through the call).
2026-06-17 23:47:08 +02:00
clement cfe429d885 fix(plip): debounce hook hangup against flicker
CI / platformio (pull_request) Failing after 5m23s
CI / platformio (push) Failing after 12m32s
The marginal A1S cradle contact flickers open mid-call; treating a
brief open as a hangup dropped the live conversation. Raise the
prolonged-open hangup threshold to 2.5 s and make the resync
asymmetric: a PICKUP is confirmed fast (600 ms, calls answer promptly)
while a HANGUP needs the line to stay open 2.5 s. Brief flickers no
longer end the call; a real hangup still fires ~2.5 s later.
2026-06-17 19:47:55 +02:00
clement 82759ee536 feat(plip): voice-activated two-phase capture
CI / platformio (push) Failing after 9m50s
CI / platformio (pull_request) Failing after 10m14s
Listen-loop capture now only commits when the caller actually speaks:
phase A waits for a sustained voice onset (3 frames above threshold,
rejecting the PA-mute click), phase B records until silence. Empty
captures are no longer posted, so the NPC never replies to silence.
Also: DC-blocking high-pass on the captured mono, VAD thresholds tuned
to the quiet SLIC handset mic (onset ~1.4%, silence ~0.6%), keep the
PA on during capture (muting it collapsed the mic), and add a
/debug/miccap diagnostic endpoint (raw fixed-duration mic capture).
2026-06-17 14:39:42 +02:00
4 changed files with 180 additions and 63 deletions
+86 -39
View File
@@ -599,66 +599,113 @@ int audio_capture_wav(uint8_t *out, size_t out_max, int max_ms, int silence_ms)
const int max_frames = (max_ms / 20);
const int silence_frames = (silence_ms / 20);
/* VAD thresholds (raw S16). Tuned for the SLIC handset mic at +24 dB PGA:
* speech peaks ~5 % FS / RMS ~1-2 %, noise floor ~0.6 %. Onset must sit
* between (catch quiet speech), silence ABOVE the noise floor (so the turn
* ends when the caller stops instead of running to max_ms). */
const int32_t vad_onset_rms_sq = 550 * 550; /* ~1.7% FS² — close-talk speech above room ambient */
const int32_t vad_silence_rms_sq = 400 * 400; /* ~1.2% FS² — above room ambient so the turn ends */
/* Calibrated to the QUIET handset voice (DC-blocked: body 0.16-0.8 % FS RMS,
* loud syllables ~2-4 %, noise floor ~0.3 %). The old onset (1.4 %) NEVER
* triggered → "no sustained voice"; the old silence (0.6 %) sat ABOVE the
* body → cut mid-sentence. Now: onset catches a real syllable just above the
* floor (3-frame confirm guards clicks); silence sits just above the floor so
* the turn ends only on a true pause, holding the whole sentence. */
const int32_t vad_onset_rms_sq = 230 * 230; /* ~0.70% FS² — quiet-voice onset */
const int32_t vad_silence_rms_sq = 110 * 110; /* ~0.34% FS² — just above noise floor */
bool voice_started = false;
int silent_frames = 0;
int total_frames = 0;
/* One-pole DC blocker (high-pass ~80 Hz) applied to the captured mono.
* The SLIC/handset path carries a huge DC/sub-audio offset (~80 % FS,
* measured). Left in, it (a) swamps the faint voice and (b) keeps the VAD
* RMS permanently above the silence threshold, so the capture never ends
* and always runs the full max_ms — and the gateway then sees only a DC
* transient, transcribing empty. Removing it lets the VAD track the real
* speech envelope (onset/silence work) and hands the gateway a clean,
* voice-dominated signal. Telephone band (300-3400 Hz) is untouched.
* y[n] = x[n] - x[n-1] + R*y[n-1]; R=0.97 → cutoff ~80 Hz at 16 kHz. */
float dc_x1 = 0.0f, dc_y1 = 0.0f;
const float dc_R = 0.97f;
ESP_LOGI(TAG, "capture: RX enabled, max_ms=%d silence_ms=%d", max_ms, silence_ms);
for (int f = 0; f < max_frames && pcm_written + frame_out_bytes <= pcm_max; f++) {
/* ── Phase A: wait for SUSTAINED voice before committing to a capture ─────
* Only start recording when the caller actually speaks. We require the
* DC-blocked RMS to stay above the onset threshold for ONSET_CONFIRM
* consecutive frames (~60 ms) — a single loud frame is rejected as a
* transient (e.g. the click when the PA mutes just before capture). Audio
* is discarded during this phase. If no sustained voice arrives within
* max_ms we return an empty WAV so the caller posts nothing (the NPC must
* not "reply" to silence). The listen loop simply calls us again. */
const int ONSET_CONFIRM = 3;
int onset_run = 0;
bool got_onset = false;
for (int f = 0; f < max_frames; f++) {
size_t bytes_read = 0;
ret = i2s_channel_read(s_mic_handle, rx_buf, frame_in_bytes,
&bytes_read, pdMS_TO_TICKS(100));
if (ret != ESP_OK || bytes_read == 0) {
ESP_LOGW(TAG, "capture: read error f=%d: %s", f, esp_err_to_name(ret));
continue;
}
/* Downmix stereo → mono, compute RMS². */
int16_t *out_frame = (int16_t *)(pcm_out + pcm_written);
int64_t rms_sq = 0;
int n_stereo = (int)(bytes_read / (2 * sizeof(int16_t)));
if (ret != ESP_OK || bytes_read == 0) continue;
int64_t rms_sq = 0;
int n_stereo = (int)(bytes_read / (2 * sizeof(int16_t)));
for (int i = 0; i < n_stereo; i++) {
int32_t l = rx_buf[i * 2];
int32_t r = rx_buf[i * 2 + 1];
int16_t mono = (int16_t)((l + r) / 2);
out_frame[i] = mono;
rms_sq += (int64_t)mono * mono;
float xf = (float)((rx_buf[i * 2] + rx_buf[i * 2 + 1]) / 2);
float yf = xf - dc_x1 + dc_R * dc_y1; /* DC-blocking high-pass */
dc_x1 = xf; dc_y1 = yf;
int32_t s = (int32_t)yf;
rms_sq += (int64_t)s * s;
}
rms_sq /= (n_stereo > 0 ? n_stereo : 1);
/* VAD logic. */
if (!voice_started) {
if (rms_sq >= vad_onset_rms_sq) {
voice_started = true;
silent_frames = 0;
ESP_LOGI(TAG, "capture: voice onset at frame %d (rms²=%"PRId64")", f, rms_sq);
if (rms_sq >= vad_onset_rms_sq) {
if (++onset_run >= ONSET_CONFIRM) {
got_onset = true;
ESP_LOGI(TAG, "capture: sustained voice onset at frame %d (rms²=%"PRId64")",
f, rms_sq);
break;
}
/* Before voice onset: still accumulate (to avoid clipping onset).
* But don't count towards silence timeout yet. */
} else {
onset_run = 0;
}
}
if (got_onset) {
/* ── Phase B: record the utterance until silence_ms of silence ──────── */
for (int f = 0; f < max_frames && pcm_written + frame_out_bytes <= pcm_max; f++) {
size_t bytes_read = 0;
ret = i2s_channel_read(s_mic_handle, rx_buf, frame_in_bytes,
&bytes_read, pdMS_TO_TICKS(100));
if (ret != ESP_OK || bytes_read == 0) {
ESP_LOGW(TAG, "capture: read error f=%d: %s", f, esp_err_to_name(ret));
continue;
}
/* Downmix stereo → mono (DC-blocked), compute RMS². */
int16_t *out_frame = (int16_t *)(pcm_out + pcm_written);
int64_t rms_sq = 0;
int n_stereo = (int)(bytes_read / (2 * sizeof(int16_t)));
for (int i = 0; i < n_stereo; i++) {
float xf = (float)((rx_buf[i * 2] + rx_buf[i * 2 + 1]) / 2);
float yf = xf - dc_x1 + dc_R * dc_y1;
dc_x1 = xf; dc_y1 = yf;
if (yf > 32767.0f) yf = 32767.0f;
else if (yf < -32768.0f) yf = -32768.0f;
int16_t mono = (int16_t)yf;
out_frame[i] = mono;
rms_sq += (int64_t)mono * mono;
}
rms_sq /= (n_stereo > 0 ? n_stereo : 1);
pcm_written += (size_t)n_stereo * sizeof(int16_t);
total_frames = f + 1;
if (rms_sq < vad_silence_rms_sq) {
silent_frames++;
if (silent_frames >= silence_frames) {
total_frames = f + 1;
ESP_LOGI(TAG, "capture: VAD end (silence %d frames at f=%d)", silent_frames, f);
pcm_written += (size_t)n_stereo * sizeof(int16_t);
if (++silent_frames >= silence_frames) {
ESP_LOGI(TAG, "capture: VAD end (silence %d frames at f=%d)",
silent_frames, f);
break;
}
} else {
silent_frames = 0;
}
}
pcm_written += (size_t)n_stereo * sizeof(int16_t);
total_frames = f + 1;
} else {
ESP_LOGI(TAG, "capture: no sustained voice in %d ms — nothing to send", max_ms);
/* pcm_written stays 0 → empty WAV → caller skips posting. */
}
/* Leave RX enabled (full-duplex, as at boot). Disabling it here would make
+12 -10
View File
@@ -47,7 +47,9 @@
#define CAPTURE_MAX_IRAM (128 * 1024)
#define CAPTURE_MAX_MS_PSRAM 8000
#define CAPTURE_MAX_MS_IRAM 4000
#define CAPTURE_SILENCE_MS 600 /* end-of-speech VAD: shorter = snappier reply (was 800) */
#define CAPTURE_SILENCE_MS 900 /* end-of-speech: needs to ride through brief
* mid-sentence pauses so a whole phrase is
* captured (quiet handset voice). 900 ms. */
#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 */
@@ -124,6 +126,10 @@ static void enter_incoming_greet(void)
{
s_incoming_armed = false;
phone_ring_stop();
slic_ring_stop(); /* belt-and-suspenders: kill the physical bell directly in
* case phone.c's s_ringing flag desynced from slic.c's
* (otherwise phone_ring_stop early-returns and the bell
* keeps ringing through the answered call). */
tones_stop();
dialer_reset();
s_offhook = true;
@@ -313,17 +319,13 @@ static void conv_task(void *arg)
if (!s_offhook) break;
vTaskDelay(pdMS_TO_TICKS(200)); /* let the I2S DMA tail drain */
/* Kill the earpiece amp during capture: at full volume + 24 dB
* mic gain the playback couples into the handset mic at ~50 %
* FS and swamps the caller's voice (capture transcribed empty).
* PA off = no echo path; restored before the reply plays. */
audio_pa_set(false);
vTaskDelay(pdMS_TO_TICKS(60)); /* PA mute settle */
/* Capture caller utterance — earpiece muted, nothing plays. */
/* Capture the caller utterance with the PA LEFT ON. The loop
* already waits for any playback to finish above, so nothing
* is driving the earpiece during capture — there is no echo to
* mute. Cutting the PA here was found to collapse the mic level
* (captures came back near-silent / DC only), so keep it on. */
int n = audio_capture_wav(cap_buf, cap_max,
cap_ms, CAPTURE_SILENCE_MS);
audio_pa_set(true); /* restore for filler/reply */
if (!s_offhook) break; /* hung up during capture */
if (n <= 44) {
+62 -1
View File
@@ -24,6 +24,7 @@
#include <errno.h>
#include "audio.h"
#include "tones.h"
#include "cmd_exec.h"
#include "phone.h"
#include "conversation.h"
@@ -600,6 +601,61 @@ static esp_err_t handle_debug_dacvol(httpd_req_t *req)
return send_json(req, "200 OK", resp);
}
/* ── GET /debug/miccap?ms=4000 (TEMP DIAG: raw fixed-duration mic capture) ───
* No VAD, DC-blocked, PA on, tones stopped. Lets us measure the true handset
* mic level while the caller speaks continuously, independent of VAD timing. */
static esp_err_t handle_debug_miccap(httpd_req_t *req)
{
char query[64] = {0};
httpd_req_get_url_query_str(req, query, sizeof(query));
char val[16] = {0};
int ms = 4000;
if (httpd_query_key_value(query, "ms", val, sizeof(val)) == ESP_OK) {
int v = atoi(val);
if (v > 0 && v <= 8000) ms = v;
}
tones_stop();
audio_pa_set(true);
if (audio_capture_begin(ms, ms) != 0) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "capture init failed");
return ESP_FAIL;
}
httpd_resp_set_type(req, "audio/wav");
const uint32_t cap_rate = 16000; const uint16_t cap_ch = 1, cap_bits = 16;
const uint32_t byte_rate = cap_rate * cap_ch * (cap_bits / 8);
const uint16_t block_align = (uint16_t)(cap_ch * (cap_bits / 8));
const uint32_t big = 0xFFFFFFFFu; uint16_t fmt_pcm = 1; uint32_t fmt_size = 16;
uint8_t hdr[44];
memcpy(hdr, "RIFF", 4); memcpy(hdr + 4, &big, 4);
memcpy(hdr + 8, "WAVE", 4); memcpy(hdr + 12, "fmt ", 4);
memcpy(hdr + 16, &fmt_size, 4); memcpy(hdr + 20, &fmt_pcm, 2);
memcpy(hdr + 22, &cap_ch, 2); memcpy(hdr + 24, &cap_rate, 4);
memcpy(hdr + 28, &byte_rate, 4); memcpy(hdr + 32, &block_align, 2);
memcpy(hdr + 34, &cap_bits, 2); memcpy(hdr + 36, "data", 4); memcpy(hdr + 40, &big, 4);
httpd_resp_send_chunk(req, (const char *)hdr, sizeof(hdr));
const int max_frames = ms / 20;
int16_t mono[320];
float dc_x1 = 0.0f, dc_y1 = 0.0f; const float dc_R = 0.97f;
for (int f = 0; f < max_frames; f++) {
int64_t rms_sq = 0;
int n = audio_capture_read_frame(mono, 320, &rms_sq);
if (n < 0) break;
if (n == 0) continue;
for (int i = 0; i < n; i++) {
float xf = (float)mono[i];
float yf = xf - dc_x1 + dc_R * dc_y1;
dc_x1 = xf; dc_y1 = yf;
if (yf > 32767.0f) yf = 32767.0f; else if (yf < -32768.0f) yf = -32768.0f;
mono[i] = (int16_t)yf;
}
if (httpd_resp_send_chunk(req, (const char *)mono, (ssize_t)(n * sizeof(int16_t))) != ESP_OK) break;
}
audio_capture_end();
httpd_resp_send_chunk(req, NULL, 0);
return ESP_OK;
}
/* ── GET /debug/getfile?path=/x.wav (read a SPIFFS file back for diagnosis) ── */
static esp_err_t handle_debug_getfile(httpd_req_t *req)
@@ -756,7 +812,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 = 16;
hcfg.max_uri_handlers = 17;
hcfg.stack_size = 8192;
esp_err_t ret = httpd_start(&s_httpd, &hcfg);
@@ -801,6 +857,10 @@ esp_err_t net_init(void)
.uri = "/debug/getfile", .method = HTTP_GET,
.handler = handle_debug_getfile, .user_ctx = NULL,
};
static const httpd_uri_t uri_debug_miccap = {
.uri = "/debug/miccap", .method = HTTP_GET,
.handler = handle_debug_miccap, .user_ctx = NULL,
};
static const httpd_uri_t uri_debug_dacvol = {
.uri = "/debug/dacvol", .method = HTTP_GET,
.handler = handle_debug_dacvol, .user_ctx = NULL,
@@ -834,6 +894,7 @@ esp_err_t net_init(void)
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_miccap);
httpd_register_uri_handler(s_httpd, &uri_debug_dacvol);
httpd_register_uri_handler(s_httpd, &uri_debug_ring);
httpd_register_uri_handler(s_httpd, &uri_debug_ringstop);
+20 -13
View File
@@ -34,20 +34,23 @@
#define TAG "phone"
#define DEBOUNCE_MS 30
#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 HANGUP_THRESHOLD_MS 2500 /* open > this → real hangup, not a pulse/flicker.
* HOOK DEBOUNCE: the A1S cradle contact is
* marginal and flickers open for up to ~2 s
* mid-call; treating those as a hangup dropped
* the live conversation. Requiring the line to
* stay open ≥ 2.5 s before hanging up rides
* through the flickers. A real hangup holds the
* line open indefinitely so it still fires
* (~2.5 s later). Rotary pulses (~60 ms) and a
* closed line 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 */
#define RESYNC_PICKUP_MS 600 /* off-hook (pickup) self-correct: fast, so an
* incoming call answers promptly. */
#define RESYNC_HANGUP_MS HANGUP_THRESHOLD_MS /* on-hook (hangup) self-correct:
* same long debounce as the prolonged-open path
* so a flickering contact never drops the call. */
/* Rotary pulse hardening (CONFIG_PLIP_DIAL_PULSE) */
#define PULSE_MIN_WIDTH_MS 20 /* ignore opens shorter than this (glitch filter) */
@@ -316,7 +319,11 @@ poll_sleep:
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) {
/* Hook debounce: a PICKUP (→off-hook) is confirmed fast so calls
* answer promptly; a HANGUP (→on-hook) needs the long debounce so
* a flickering cradle contact can't drop a live call. */
int need = phys_off ? RESYNC_PICKUP_MS : RESYNC_HANGUP_MS;
if (resync_ms >= need) {
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;