13 KiB
OTA 后首次语音启动与后台上传隔离修复说明
历史实现记录:文中远程日志及 LogFS 上传路径已从当前固件移除,相关描述不代表现行行为。
1. 提交信息
609fe97 fix(multimodal): protect voice startup from background uploads
本提交解决 OTA 加入后容易出现的以下现象:
- 开机后第一次说“喵掌柜”,设备识别到了唤醒词,但没有进入录音和回答流程。
- 第二次唤醒能够成功,但第一次正式回答等待时间明显偏长。
- 后续多轮对话又恢复正常。
- 第一次失败后,旧 task 可能延迟进入
Listening,影响下一次唤醒的状态判断。
2. 问题原因
2.1 第一次并不是没有识别到唤醒词
问题日志中的关键时间线为:
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 前,主要顺序是:
调度 WebSocket
-> 启动本地唤醒
-> 启动远程日志服务
加入 OTA 后,初始 OTA HTTPS window 会暂时禁止语音服务:
启动 OTA
-> WebSocket 调度被暂时拒绝
-> 本地唤醒被延迟
-> 远程日志服务仍开始运行
-> OTA 完成
-> WebSocket、本地唤醒、日志上传同时恢复或继续
问题设备启动时还有以下积压任务:
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 原来的上传避让条件不完整
旧逻辑只把以下状态当作实时链路忙碌:
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 开始之间没有明确状态
用户停止讲话后:
client_speech_active=0
remote_responding=0
received_downstream_audio=0
但此时本轮对话并没有结束,设备正在等待云端生成回答。旧状态结构无法表达“正在等回答”,远程日志可能在这个窗口开始上传,进一步放大首轮回答延迟。
2.5 首次超时后遗留了迟到 task
旧的本地唤醒超时处理只执行:
control_plane_set_ptt_active(false);
这只会退出本地前台唤醒流程,不会停止已经发送到云端的 task。
因此出现了以下状态变化:
第一次超时时:task=1, listening=0
第二次唤醒时:task=1, listening=1
旧 task 在第一次已经失败后才进入 Listening。第二次唤醒直接复用了它,所以第二次和后续对话看起来又正常了。
如果旧 task 的 Listening 事件到达得更晚,还可能污染一个刚创建的新 task。
3. 解决方式
3.1 启动阶段延迟远程日志上传
增加 60 秒语音启动保护时间:
#define REMOTE_LOG_VOICE_STARTUP_GRACE_MS 60000
启动保护并不是简单地固定等待 60 秒。如果语音 task 提前准备完成,可以提前打开上传门:
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 打开后才会执行网络上传:
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 扩大实时语音忙碌判断
新的避让判断为:
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 增加等待回答状态
运行状态增加:
bool response_pending;
会话内部保存对应状态:
static bool s_response_pending;
成功发送 StopSpeech 或收到 SpeechEnded 后设置为 true:
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();
}
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 或会话重置时清零:
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:
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:
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:
cJSON *message_task_id = header ?
cJSON_GetObjectItemCaseSensitive(header, "task_id") : NULL;
收到 Listening 时必须同时满足:
- 本地当前存在 active task;
- 如果消息携带 task ID,它必须与当前 task ID 相同。
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 增加关键时序日志
新增日志:
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,可以直接计算:
Start 发送耗时
task-started 到达时间
Listening 到达时间
本地唤醒等待总时间
4. 解决成果
- 开机 OTA 检查期间,历史日志和 coredump 不会抢占首次语音链路需要的 HTTPS、CPU 和内存资源。
- WebSocket/task 启动、等待
Listening、录音、等待回答和播放回复期间,后台上传都会主动避让。 - ASR 结束到 TTS 开始之间有明确状态,不再被误判为空闲窗口。
- 首次 task 超时后会被清理,不会由第二次唤醒继续复用一个来源不明的迟到 task。
- 旧 task 的迟到
Listening不会污染新 task 状态。 - 串口日志能够明确展示 task-started 和
Listening的到达时刻。 - 使用 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 仍需实机验证的项目
- 冷启动后立即说“喵掌柜”,确认第一次即可进入
Listening和录音。 - 检查首次唤醒期间不再出现远程日志上传产生的 TLS 连接。
- 确认日志顺序包含
Start sent、Cloud task started、Cloud dialog entered Listening和Local wake network ready。 - 模拟服务端 task 启动超过 10 秒,确认出现
Local wake timed-out task cleanup,下一次唤醒创建新 task。 - 在 ASR 结束到 TTS 开始期间观察远程日志,确认没有启动后台 HTTPS 上传。
- 开机 60 秒后保持语音空闲,确认历史日志和 coredump 能继续上传,而不是永久被阻塞。