diff --git a/components/control_plane/src/runtime/control_plane_runtime_workers.c b/components/control_plane/src/runtime/control_plane_runtime_workers.c index 13544fc..0c5baed 100644 --- a/components/control_plane/src/runtime/control_plane_runtime_workers.c +++ b/components/control_plane/src/runtime/control_plane_runtime_workers.c @@ -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"); diff --git a/docs/local-speech-endpoint.zh-CN.md b/docs/local-speech-endpoint.zh-CN.md index 3bf4201..9c9eca2 100644 --- a/docs/local-speech-endpoint.zh-CN.md +++ b/docs/local-speech-endpoint.zh-CN.md @@ -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` 通过。 +- 设备:未烧录。首次录音未提前断句的声学原因仍需新日志和实机复测;本次主机测试 + 与构建不能证明真实噪声环境下的人声/静音识别效果。 diff --git a/tools/test_speech_endpoint.py b/tools/test_speech_endpoint.py index 6fd27c5..9a697ad 100644 --- a/tools/test_speech_endpoint.py +++ b/tools/test_speech_endpoint.py @@ -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