fix(control_plane): preserve VAD generation on repeated PTT activation

This commit is contained in:
chenjintao
2026-10-23 14:09:34 +08:00
parent f17ce857ff
commit efd7aac623
3 changed files with 169 additions and 5 deletions
@@ -720,6 +720,14 @@ static bool control_plane_deactivate_ptt(
return true;
}
/* 调用方持有 s_runtime_lock;收尾期间仍允许排队下一轮。 */
static bool control_plane_ptt_has_active_turn_locked(void)
{
return s_ptt_runtime.active &&
s_ptt_runtime.state != PTT_STATE_TEARDOWN_LOCAL &&
s_ptt_runtime.state != PTT_STATE_TEARDOWN_REMOTE;
}
static void control_plane_set_ptt_active_with_trigger(
bool active,
domain_expression_trigger_t listening_ui_trigger)
@@ -729,6 +737,14 @@ static void control_plane_set_ptt_active_with_trigger(
return;
}
portENTER_CRITICAL(&s_runtime_lock);
bool duplicate = control_plane_ptt_has_active_turn_locked();
portEXIT_CRITICAL(&s_runtime_lock);
if (duplicate) {
ESP_LOGI(TAG, "PTT activation ignored: turn already active");
return;
}
if (!control_plane_ota_business_interactions_allowed()) {
ESP_LOGI(TAG, "PTT activation ignored during OTA");
return;
@@ -746,6 +762,12 @@ static void control_plane_set_ptt_active_with_trigger(
ptt_state_t new_state = PTT_STATE_IDLE;
uint32_t generation = 0;
portENTER_CRITICAL(&s_runtime_lock);
/* 前置检查会让出执行权;提交意图前再次检查,防止并发入口换代。 */
if (control_plane_ptt_has_active_turn_locked()) {
portEXIT_CRITICAL(&s_runtime_lock);
ESP_LOGI(TAG, "PTT activation ignored: turn already active");
return;
}
old_state = s_ptt_runtime.state;
s_ws_start_requested = true;
s_ptt_runtime.generation++;
@@ -1915,6 +1937,21 @@ static bool control_plane_service_ptt_lifecycle(void)
if (!local_wake_prompt_gate &&
ptt.press_tick != 0 &&
(now - ptt.press_tick) >= capture_timeout_ticks) {
#if CONFIG_TQ_SPEECH_ENDPOINT_ENABLE
domain_speech_endpoint_snapshot_t endpoint = {0};
esp_err_t endpoint_err = domain_speech_endpoint_get_snapshot(&endpoint);
if (endpoint_err == ESP_OK) {
ESP_LOGI(TAG, "VAD capture timeout: ptt_generation=%u endpoint_generation=%u active=%d speech=%d ended=%d audio_ms=%u silence_ms=%u max_process_us=%u gaps=%u",
(unsigned)ptt.generation, (unsigned)endpoint.generation,
endpoint.active ? 1 : 0, endpoint.speech_confirmed ? 1 : 0,
endpoint.ended ? 1 : 0, (unsigned)endpoint.audio_ms,
(unsigned)endpoint.silence_ms, (unsigned)endpoint.max_process_us,
(unsigned)endpoint.discontinuities);
} else {
ESP_LOGW(TAG, "VAD capture timeout snapshot unavailable: %s",
esp_err_to_name(endpoint_err));
}
#endif
(void)control_plane_deactivate_ptt(true,
ptt.generation,
"capture_timeout");
+30 -3
View File
@@ -1,6 +1,6 @@
# 本地 VAD 语音端点检测
更新:2026-10-15。适用于 lucky-cat;实现所有权见 [ADR 0006](adr/0006-local-speech-endpoint.md)。
更新:2026-10-23。适用于 lucky-cat;实现所有权见 [ADR 0006](adr/0006-local-speech-endpoint.md)。
## 当前行为
@@ -18,6 +18,11 @@
原窗口起点,可能包含连接等待;本次不改变这一点。持续静音或未达到人声阈值的
短促声音不触发提前结束,继续走原超时。阈值以 20 ms 检测帧向上取整。
等待连接、等待提示音和拾音期间,重复按键/唤醒请求沿用当前轮次,不递增
generation、不重置输入窗口,也不重新启用 VAD。收尾期间保留原有下一轮排队机制。
当前单击模式关闭长按识别,长按松开也可能产生 `SHORT_PRESS`;该事件同样受上述
幂等保护,不能让仍在录音的控制层换代并遗留旧代次 VAD。
900 ms 指 VAD 输出连续静音,不等于从物理声音结束到 StopSpeech 的精确时长;
库内判定延迟、20 ms 分帧、现有 80 ms 控制轮询、调度及尾包发送都会影响时间。
句中停顿超过阈值可能被截断;慢语速、轻声、儿童、远场及噪声需实机调参。
@@ -47,6 +52,11 @@
- `VAD endpoint`:代次、处理音频时长、连续静音、最大回调处理墙钟时间与间断数。
- `StopSpeech sent after uplink drain`:正常输入结束指令已发出。
- `VAD unavailable; retain capture timeout`:本轮回退原输入窗口。
- `PTT activation ignored: turn already active`:重复触发已忽略,原代次与超时窗口保留。
- `VAD capture timeout`:兜底结束前的一次领域快照,包含 PTT/端点代次、活动状态、
是否已确认人声、是否结束、处理音频时长、当前连续静音、最大回调墙钟时间和间断数。
`speech=0` 表示尚未确认连续人声;`audio_ms` 很小或 `gaps` 较多可提示输入问题。
这是瞬时状态,不能据此还原整段声学分类;查询失败只记录错误,继续原超时收尾。
- `Abort voice turn after incomplete drain`:失败废弃,不能记作成功回复。
固定 PCM 重分帧数组为 640 B,另有领域状态/互斥锁和平台第二个观察者槽、
@@ -60,13 +70,16 @@ VAD 库内部动态分配、原采集栈高水位和联网后的最小内部 RAM
`python3 tools/test_speech_endpoint.py` 编译并执行生产 C,使用可控 VAD/RTOS 桩:
覆盖分帧、纯静音、短噪声、句中停顿、代次、输入间断、初始化失败、注销超时、
订阅并发替换、队列出队与 busy 竞态、原采集上下文尾帧补零、排空与失败重试。
订阅并发替换、队列出队与 busy 竞态、原采集上下文尾帧补零、排空与失败重试;
控制层直接执行生产激活函数,覆盖各等待阶段/拾音阶段重复触发、原端点继续生效、
超时不续期、收尾排队、前置检查期间并发触发和快照查询失败后的兜底。
这些测试验证控制逻辑,不验证真实声学识别率或 ESP32 时序。
实机至少检查:
1. 按键和唤醒各测试短句、长句、未说话、轻声、噪声和不同长度句中停顿。
2. 连续多轮、快速取消/重新触发、服务端提前结束、TTS 播放及唤醒提示音门禁。
2. 连续多轮、拾音中重复单击/长按松开、快速取消/重新触发、服务端提前结束、
TTS 播放及唤醒提示音门禁。重复触发应保持代次和原窗口,收尾后新一轮仍可正常启动。
3. 拾音期间断网、OTA/待机切换、发送拥塞、内存不足与重复初始化。
4. 记录最后人声 → endpoint → StopSpeech → 首个下行音频 → 实际播放的分段延迟;
同时统计误截断、未判停、队列丢包、回调耗时、栈高水位和内部 RAM 最低余量。
@@ -88,3 +101,17 @@ VAD 库内部动态分配、原采集栈高水位和联网后的最小内部 RAM
处失败,已对照 HEAD 确认不是本次引入。仅排除该既有检查后,其余架构检查通过;
本次新增路径无无限等待或跨层内部头依赖。`git diff --check` 通过。
- 设备:未烧录,尚无实机误截断率、延迟收益、VAD 动态堆和栈峰值数据。
### 2026-10-23 重复激活修复验证
- 修复:控制层在前置检查前及提交意图的临界区内检查活动轮次,忽略重复激活;
保持领域端点的代次隔离,不替换正在使用的 VAD。新增超时诊断只在兜底时记录。
- 主机:重新运行 `test_speech_endpoint.py` 全部通过,包含 VAD 开启/关闭的领域和
控制层 C 分支;上述五个显示回归脚本也全部通过。
- 构建:激活 ESP-IDF 5.5.4 后执行 `idf.py build` 成功,使用当前 VAD 开启配置;
本次未执行关闭配置的固件构建。应用镜像 3,168,368 B,单个 OTA 槽剩余 1,550,224 B。
本次不新增任务、队列、堆分配或固定音频/DMA 缓冲。
- 架构:原守卫仍停在电量模块既有的 `portMAX_DELAY`,已对照 HEAD 确认;仅排除
该既有检查后,其余守卫通过。`git diff --check` 通过。
- 设备:未烧录。首次录音未提前断句的声学原因仍需新日志和实机复测;本次主机测试
与构建不能证明真实噪声环境下的人声/静音识别效果。
+102 -2
View File
@@ -272,11 +272,12 @@ CP_TEST = r"""
static ptt_runtime_t ptt;
static domain_speech_endpoint_snapshot_t endpoint;
static bool gate, change_generation;
static int deactivated;
static int deactivated, snapshot_error;
static const char *last_reason;
static ptt_runtime_t control_plane_ptt_snapshot(void) {return ptt;}
static int domain_speech_endpoint_stop(void) {endpoint.active=false;return 0;}
static int domain_speech_endpoint_get_snapshot(domain_speech_endpoint_snapshot_t *out) {
if(snapshot_error) return snapshot_error;
*out=endpoint; if(change_generation) {++ptt.generation;change_generation=false;} return 0;
}
static bool control_plane_local_wake_prompt_gate_active(void) {return gate;}
@@ -288,6 +289,7 @@ static void control_plane_ptt_set_state(ptt_state_t state,const char *why) {(voi
"""
CP_MAIN = r"""
int main(void) {
test_activation();
ticks=100; ptt=(ptt_runtime_t){.active=true,.state=PTT_STATE_STREAMING,.generation=7,.press_tick=1};
endpoint=(domain_speech_endpoint_snapshot_t){.active=true,.ended=true,.generation=6};
assert(!control_plane_service_ptt_lifecycle() && deactivated==0);
@@ -298,11 +300,93 @@ int main(void) {
ptt.active=true; ptt.state=PTT_STATE_STREAMING; endpoint.active=false;
assert(!control_plane_service_ptt_lifecycle()); ticks=8001;
assert(control_plane_service_ptt_lifecycle() && deactivated==2 && !strcmp(last_reason,"capture_timeout"));
ptt.active=true; snapshot_error=ESP_ERR_TIMEOUT;
assert(control_plane_service_ptt_lifecycle() && deactivated==3 && !strcmp(last_reason,"capture_timeout"));
snapshot_error=0;
ptt=(ptt_runtime_t){.state=PTT_STATE_IDLE}; endpoint.active=true;
assert(!control_plane_service_ptt_lifecycle() && !endpoint.active);
puts("PASS: actual control-plane C stale-generation rejection, prompt gate, concurrent cancel and 8s fallback");
}
"""
CP_ACTIVATION_STUB = r"""
#define s_ptt_runtime ptt
#define CONTROL_PLANE_NETWORK_ACTION_CONVERSATION 1
static int s_runtime_lock, pauses, resumes, expressions, notifications, readiness_calls;
static bool s_ws_start_requested, ota_allowed=true;
static esp_err_t readiness_error;
static void (*readiness_hook)(void);
static bool control_plane_ota_business_interactions_allowed(void) {return ota_allowed;}
static void control_plane_local_wake_pause_for_foreground(void) {++pauses;}
static void control_plane_local_wake_resume_after_foreground(void) {++resumes;}
static esp_err_t control_plane_network_feedback_require_ready(int action) {
(void)action; ++readiness_calls; if(readiness_hook) readiness_hook(); return readiness_error;
}
static void control_plane_ptt_transition_locked(ptt_state_t state,const char *reason) {
(void)reason; ptt.state=state; ptt.state_enter_tick=ticks;
}
static void control_plane_ptt_log_transition(ptt_state_t a,ptt_state_t b,uint32_t gen,const char *why) {
(void)a;(void)b;(void)gen;(void)why;
}
static void control_plane_request_ptt_expression(domain_expression_id_t id,domain_expression_trigger_t trigger) {
(void)id;(void)trigger;++expressions;
}
static void control_plane_notify_wifi_state_task(void) {++notifications;}
"""
CP_ACTIVATION_TEST = r"""
static void concurrent_activation(void) {
readiness_hook=NULL;
control_plane_set_ptt_active_with_trigger(true,DOMAIN_EXPRESSION_TRIGGER_LOCAL_WAKE);
}
static void test_activation(void) {
const ptt_state_t stages[]={PTT_STATE_WAIT_WIFI,PTT_STATE_WAIT_WS,PTT_STATE_WAIT_START_SENT,
PTT_STATE_WAIT_LISTENING,PTT_STATE_WAIT_WAKE_PROMPT,PTT_STATE_WAIT_SPEECH_STARTED,
PTT_STATE_STREAMING};
ticks=100; ptt=(ptt_runtime_t){.state=PTT_STATE_IDLE};
ota_allowed=false;
control_plane_set_ptt_active_with_trigger(true,DOMAIN_EXPRESSION_TRIGGER_PTT_BUTTON);
assert(!ptt.active && !pauses && !readiness_calls);
ota_allowed=true; readiness_error=ESP_ERR_INVALID_STATE;
control_plane_set_ptt_active_with_trigger(true,DOMAIN_EXPRESSION_TRIGGER_PTT_BUTTON);
assert(!ptt.active && resumes==1);
readiness_error=ESP_OK;
control_plane_set_ptt_active_with_trigger(true,DOMAIN_EXPRESSION_TRIGGER_PTT_BUTTON);
assert(ptt.active && ptt.generation==1 && ptt.press_tick==100 && expressions==1);
assert(ptt.state==PTT_STATE_WAIT_WIFI && s_ws_start_requested && notifications==1);
for(unsigned i=0;i<sizeof(stages)/sizeof(stages[0]);++i) {
ptt.state=stages[i]; ptt.endpoint_start_attempted=true;
ptt.start_action_sent=true; ptt.speech_action_sent=true;
ptt_runtime_t before=ptt;
int previous_pauses=pauses, previous_ready=readiness_calls;
ticks+=1000;
control_plane_set_ptt_active_with_trigger(true,DOMAIN_EXPRESSION_TRIGGER_LOCAL_WAKE);
assert(!memcmp(&before,&ptt,sizeof(ptt)));
assert(pauses==previous_pauses && readiness_calls==previous_ready && expressions==1);
}
/* 重复触发后仍接收原代次端点;窗口也不能被续期。 */
endpoint=(domain_speech_endpoint_snapshot_t){.active=true,.ended=true,.generation=ptt.generation};
assert(control_plane_service_ptt_lifecycle() && !strcmp(last_reason,"vad_endpoint"));
ptt=(ptt_runtime_t){.active=true,.state=PTT_STATE_STREAMING,.generation=9,.press_tick=100};
endpoint=(domain_speech_endpoint_snapshot_t){0}; ticks=8099;
control_plane_set_ptt_active_with_trigger(true,DOMAIN_EXPRESSION_TRIGGER_PTT_BUTTON);
assert(!control_plane_service_ptt_lifecycle()); ++ticks;
assert(control_plane_service_ptt_lifecycle() && !strcmp(last_reason,"capture_timeout"));
const ptt_state_t teardown[]={PTT_STATE_TEARDOWN_LOCAL,PTT_STATE_TEARDOWN_REMOTE};
for(unsigned i=0;i<2;++i) {
ptt=(ptt_runtime_t){.state=teardown[i],.generation=10};
control_plane_set_ptt_active_with_trigger(true,DOMAIN_EXPRESSION_TRIGGER_PTT_BUTTON);
assert(ptt.active && ptt.generation==11 && ptt.pending_press_after_teardown);
assert(ptt.state==teardown[i] && !ptt.endpoint_start_attempted);
}
ptt=(ptt_runtime_t){.state=PTT_STATE_IDLE,.generation=20};
readiness_hook=concurrent_activation;
int previous_expressions=expressions;
control_plane_set_ptt_active_with_trigger(true,DOMAIN_EXPRESSION_TRIGGER_PTT_BUTTON);
assert(ptt.generation==21 && expressions==previous_expressions+1);
assert(ptt.listening_ui_trigger==DOMAIN_EXPRESSION_TRIGGER_LOCAL_WAKE);
deactivated=0;
puts("PASS: actual PTT activation C ignores repeats, preserves endpoint/window, queues teardown and rejects concurrent duplicate");
}
"""
PENDING_TEST = r"""
#include "common.h"
typedef struct {bool uplink_send_in_flight;void *uplink_send_queue;} multimodal_audio_ctx_t;
@@ -362,7 +446,23 @@ puts("PASS: disabled domain C fallback"); }
cp_types=re.search(r'typedef enum \{\s+PTT_STATE_IDLE.*?} ptt_runtime_t;',cp_source,re.S).group(0)
# Hide facade declarations only for test doubles; production function is unchanged.
cp_test=CP_TEST.replace('static int domain_speech_endpoint_', 'int domain_speech_endpoint_')
compile_run(tmp, 'control_plane', CP_STUB + cp_types + cp_test + extract(cp_source, 'control_plane_service_ptt_lifecycle') + CP_MAIN)
cp_lifecycle=extract(cp_source, 'control_plane_service_ptt_lifecycle')
cp_activation='\n'.join(extract(cp_source, n) for n in [
'control_plane_ptt_has_active_turn_locked', 'control_plane_set_ptt_active_with_trigger'])
compile_run(tmp, 'control_plane', CP_STUB + cp_types + cp_test + CP_ACTIVATION_STUB +
cp_activation + cp_lifecycle + CP_ACTIVATION_TEST + CP_MAIN)
compile_run(tmp, 'control_plane_disabled', CP_STUB.replace(
'#define CONFIG_TQ_SPEECH_ENDPOINT_ENABLE 1', '#define CONFIG_TQ_SPEECH_ENDPOINT_ENABLE 0') +
cp_types + cp_test + CP_ACTIVATION_STUB + cp_activation + cp_lifecycle + r'''
int main(void) {
ticks=100; control_plane_set_ptt_active_with_trigger(true,DOMAIN_EXPRESSION_TRIGGER_PTT_BUTTON);
ptt.state=PTT_STATE_STREAMING;
ticks=8100; control_plane_set_ptt_active_with_trigger(true,DOMAIN_EXPRESSION_TRIGGER_LOCAL_WAKE);
assert(ptt.generation==1 && ptt.press_tick==100);
assert(control_plane_service_ptt_lifecycle() && !strcmp(last_reason,"capture_timeout"));
puts("PASS: VAD disabled control-plane C preserves generation and 8s fallback on repeated activation");
}
''')
compile_run(tmp, 'stop', STOP_STUB + '\n'.join(extract(AUDIO, n) for n in ['audio_uplink_tail_pending', 'audio_flush_uplink_tail', 'multimodal_audio_stop_uplink']) + STOP_START_STUB + extract(AUDIO, 'multimodal_audio_start_uplink') + STOP_TEST)
# Integration guards supplement C execution; they do not replace device timing tests.