# OTA 后首次语音启动与后台上传隔离修复说明 > 历史实现记录:文中远程日志及 LogFS 上传路径已从当前固件移除,相关描述不代表现行行为。 ## 1. 提交信息 ```text 609fe97 fix(multimodal): protect voice startup from background uploads ``` 本提交解决 OTA 加入后容易出现的以下现象: 1. 开机后第一次说“喵掌柜”,设备识别到了唤醒词,但没有进入录音和回答流程。 2. 第二次唤醒能够成功,但第一次正式回答等待时间明显偏长。 3. 后续多轮对话又恢复正常。 4. 第一次失败后,旧 task 可能延迟进入 `Listening`,影响下一次唤醒的状态判断。 ## 2. 问题原因 ### 2.1 第一次并不是没有识别到唤醒词 问题日志中的关键时间线为: ```text 15.755s OTA 服务启动 15.835s 本地唤醒因 OTA HTTPS window 延迟启动 18.925s OTA 检查返回 HTTP 204 18.945s 本地唤醒开始恢复 19.935s 多模态鉴权开始 21.935s WebSocket 连接成功 32.065s 本地识别到“喵掌柜” 32.165s Start 已发送 32.255s task=1, listening=0 42.135s 等待 Listening 超过 10 秒,本地唤醒超时 ``` 因此首次失败点不是本地关键词识别,而是设备发出 `Start` 后,云端 task 没能在固件规定的 10 秒内进入 `Listening`。 ### 2.2 OTA 改变了启动顺序 加入 OTA 前,主要顺序是: ```text 调度 WebSocket -> 启动本地唤醒 -> 启动远程日志服务 ``` 加入 OTA 后,初始 OTA HTTPS window 会暂时禁止语音服务: ```text 启动 OTA -> WebSocket 调度被暂时拒绝 -> 本地唤醒被延迟 -> 远程日志服务仍开始运行 -> OTA 完成 -> WebSocket、本地唤醒、日志上传同时恢复或继续 ``` 问题设备启动时还有以下积压任务: ```text pending_files=12 coredump_present=1 coredump_size=41732 ``` 这意味着 OTA 结束后,语音链路首次初始化会与历史日志和 coredump 的 HTTPS 上传竞争: - Wi-Fi 传输时间; - TLS 握手; - CPU 时间; - internal RAM 和 DMA 连续内存; - 网络任务调度时间。 第一次等待 `Listening` 期间还出现了新的 TLS 证书验证日志,证明后台 HTTPS 请求确实与首次 task 建立发生了重叠。 ### 2.3 原来的上传避让条件不完整 旧逻辑只把以下状态当作实时链路忙碌: ```c return state.client_speech_active || state.remote_responding || state.request_to_speak_pending || state.received_downstream_audio; ``` 它没有覆盖: - OTA 正占用 voice services; - WebSocket 正在启动; - 本地唤醒已经进入前台,但还在等待网络; - task 已创建但 `Listening` 尚未到达; - ASR 已结束、正在等待模型回答; - 本地回复事件尚未结束。 因此后台日志线程会误以为设备空闲,并在这些关键阶段发起新的 HTTPS 上传。 ### 2.4 ASR 结束到 TTS 开始之间没有明确状态 用户停止讲话后: ```text client_speech_active=0 remote_responding=0 received_downstream_audio=0 ``` 但此时本轮对话并没有结束,设备正在等待云端生成回答。旧状态结构无法表达“正在等回答”,远程日志可能在这个窗口开始上传,进一步放大首轮回答延迟。 ### 2.5 首次超时后遗留了迟到 task 旧的本地唤醒超时处理只执行: ```c control_plane_set_ptt_active(false); ``` 这只会退出本地前台唤醒流程,不会停止已经发送到云端的 task。 因此出现了以下状态变化: ```text 第一次超时时:task=1, listening=0 第二次唤醒时:task=1, listening=1 ``` 旧 task 在第一次已经失败后才进入 `Listening`。第二次唤醒直接复用了它,所以第二次和后续对话看起来又正常了。 如果旧 task 的 `Listening` 事件到达得更晚,还可能污染一个刚创建的新 task。 ## 3. 解决方式 ### 3.1 启动阶段延迟远程日志上传 增加 60 秒语音启动保护时间: ```c #define REMOTE_LOG_VOICE_STARTUP_GRACE_MS 60000 ``` 启动保护并不是简单地固定等待 60 秒。如果语音 task 提前准备完成,可以提前打开上传门: ```c static bool voice_startup_upload_gate_open(TickType_t startup_tick) { #if CONFIG_TQ_LOCAL_WAKE_ENABLE && CONFIG_ENABLE_MULTIMODAL_INTERACTION control_plane_multimodal_runtime_state_t state = {0}; if (control_plane_multimodal_get_runtime_state(&state) == ESP_OK && state.connected && state.has_active_task && state.server_listening) { return true; } return (xTaskGetTickCount() - startup_tick) >= pdMS_TO_TICKS(REMOTE_LOG_VOICE_STARTUP_GRACE_MS); #else (void)startup_tick; return true; #endif } ``` 上传循环只有在 gate 打开后才会执行网络上传: ```c TickType_t last_flush = xTaskGetTickCount(); TickType_t startup_tick = last_flush; while (!s_stop_requested) { TickType_t now = xTaskGetTickCount(); if (voice_startup_upload_gate_open(startup_tick) && (backoff_ms == 0U || now >= next_upload_tick)) { drain_memory_queue_to_flash(); (void)upload_coredump_report(); bool ok = upload_pending_files(); /* 后续退避处理保持不变 */ } } ``` 保护期间远程日志仍会写入内存队列和 LogFS,只延迟上传到服务端,不会丢失日志采集能力。 ### 3.2 扩大实时语音忙碌判断 新的避让判断为: ```c static bool realtime_path_busy(void) { #if CONFIG_TQ_LOCAL_WAKE_ENABLE if (!control_plane_ota_voice_services_allowed() || !control_plane_voice_turn_is_idle()) { return true; } #endif #if CONFIG_ENABLE_MULTIMODAL_INTERACTION control_plane_multimodal_runtime_state_t state = {0}; if (control_plane_multimodal_get_runtime_state(&state) != ESP_OK) { return false; } return state.starting || (state.connected && state.has_active_task && !state.server_listening) || state.client_speech_active || state.response_pending || state.remote_responding || state.request_to_speak_pending || state.local_responding_started || state.received_downstream_audio; #else return false; #endif } ``` 现在后台上传会避让完整的语音关键路径,而不只是避让“正在录音”和“正在播放”。 ### 3.3 增加等待回答状态 运行状态增加: ```c bool response_pending; ``` 会话内部保存对应状态: ```c static bool s_response_pending; ``` 成功发送 `StopSpeech` 或收到 `SpeechEnded` 后设置为 `true`: ```c if (lock_state()) { s_client_speech_active = false; s_ptt_release_required = false; if (err == ESP_OK && has_active_task) { s_response_pending = true; } s_request_to_speak_pending = false; unlock_state(); } ``` ```c if (strcmp(output_event->valuestring, "SpeechEnded") == 0) { control_plane_multimodal_stop_uplink(); if (lock_state()) { s_ptt_release_required = s_client_speech_active; s_response_pending = true; unlock_state(); } ESP_LOGI(TAG, "ASR ended"); } ``` 开始收到回复、开始新一轮语音、取消语音、Stop 或会话重置时清零: ```c if (strcmp(output_event->valuestring, "RespondingStarted") == 0) { if (lock_state()) { s_response_pending = false; s_remote_responding = true; unlock_state(); } } ``` 这样,从用户说完话到 TTS 开始之间也属于实时链路忙碌期,后台 HTTPS 不会插入。 ### 3.4 首次 Listening 超时后清理未完成 task 超时后先判断它是否确实是一个“未完成启动”的 task: ```c bool incomplete_task_start = runtime.connected && runtime.has_active_task && !runtime.server_listening && !runtime.remote_responding && !runtime.request_to_speak_pending && !runtime.client_speech_active; ``` 只有满足这些条件才发送 Stop: ```c if (incomplete_task_start) { esp_err_t stop_err = control_plane_multimodal_send_stop( "LocalWakeListeningTimeout"); ESP_LOGW(TAG, "Local wake timed-out task cleanup: result=%s", esp_err_to_name(stop_err)); } control_plane_set_ptt_active(false); ``` 限制条件避免误停以下正常场景: - 云端正在回答; - 正在进行打断请求; - 用户语音仍在发送; - task 已经进入 `Listening`,只是其他前台条件暂时未满足。 `control_plane_multimodal_send_stop()` 会发送 `finish-task/Stop`,随后清理本地 task、dialog 和响应状态。WebSocket 本身仍可复用,下一次唤醒会创建一个干净的新 task。 ### 3.5 过滤迟到的 Listening 事件 解析服务端消息时读取其 task ID: ```c cJSON *message_task_id = header ? cJSON_GetObjectItemCaseSensitive(header, "task_id") : NULL; ``` 收到 `Listening` 时必须同时满足: - 本地当前存在 active task; - 如果消息携带 task ID,它必须与当前 task ID 相同。 ```c bool accepted = false; if (lock_state()) { accepted = s_task_id[0] != '\0' && (!cJSON_IsString(message_task_id) || message_task_id->valuestring == NULL || strcmp(s_task_id, message_task_id->valuestring) == 0); if (accepted) { s_server_listening = true; s_remote_responding = false; s_remote_responding_ended = false; s_request_to_speak_pending = false; } unlock_state(); } if (!accepted) { ESP_LOGW(TAG, "Ignore stale Listening event for task_id=%s", cJSON_IsString(message_task_id) && message_task_id->valuestring != NULL ? message_task_id->valuestring : "-"); cJSON_Delete(root); return; } ``` 这可以防止旧 task 的迟到事件把新会话错误标记为已准备好。 ### 3.6 增加关键时序日志 新增日志: ```c ESP_LOGI(TAG, "Cloud task started"); ESP_LOGI(TAG, "Cloud task started (output event)"); ESP_LOGI(TAG, "Cloud dialog entered Listening"); ``` 配合已有的 `Start sent` 和 `Local wake network ready`,可以直接计算: ```text Start 发送耗时 task-started 到达时间 Listening 到达时间 本地唤醒等待总时间 ``` ## 4. 解决成果 1. 开机 OTA 检查期间,历史日志和 coredump 不会抢占首次语音链路需要的 HTTPS、CPU 和内存资源。 2. WebSocket/task 启动、等待 `Listening`、录音、等待回答和播放回复期间,后台上传都会主动避让。 3. ASR 结束到 TTS 开始之间有明确状态,不再被误判为空闲窗口。 4. 首次 task 超时后会被清理,不会由第二次唤醒继续复用一个来源不明的迟到 task。 5. 旧 task 的迟到 `Listening` 不会污染新 task 状态。 6. 串口日志能够明确展示 task-started 和 `Listening` 的到达时刻。 7. 使用 ESP-IDF 5.5.4 完整编译和增量编译均通过。 ## 5. 后续影响 ### 5.1 远程日志上传会延迟 开机后的日志和 coredump 网络上传最多延迟 60 秒。如果语音 task 提前进入 `Listening`,并且当前没有正在进行的语音交互,可以提前上传。 这只影响“上传时间”,不影响本地日志采集和写入 LogFS。对于启动崩溃反复重启的设备,coredump 可能无法在 60 秒内上报,需要结合设备稳定性和问题诊断优先级评估是否调整该时间。 ### 5.2 连续对话会继续推迟后台上传 如果用户持续对话,远程日志会持续避让。待语音链路真正空闲后,积压文件会按照原有退避策略继续上传。 这是有意的优先级选择:实时语音体验优先于后台诊断数据的即时送达。 ### 5.3 超时后下一次唤醒会创建新 task 首次 `Listening` 超时后,旧 task 被 Stop 并清理。下一次唤醒可能比直接复用迟到 task 多一次 `Start` 请求,但状态是确定且干净的,不会出现“第二次碰巧成功”的隐式恢复。 ### 5.4 状态字段影响很小 新增的 `response_pending` 是一个布尔状态,对内存占用可以忽略。它由现有会话锁保护,没有新增 worker、队列或长期资源。 ### 5.5 仍需实机验证的项目 1. 冷启动后立即说“喵掌柜”,确认第一次即可进入 `Listening` 和录音。 2. 检查首次唤醒期间不再出现远程日志上传产生的 TLS 连接。 3. 确认日志顺序包含 `Start sent`、`Cloud task started`、`Cloud dialog entered Listening` 和 `Local wake network ready`。 4. 模拟服务端 task 启动超过 10 秒,确认出现 `Local wake timed-out task cleanup`,下一次唤醒创建新 task。 5. 在 ASR 结束到 TTS 开始期间观察远程日志,确认没有启动后台 HTTPS 上传。 6. 开机 60 秒后保持语音空闲,确认历史日志和 coredump 能继续上传,而不是永久被阻塞。