Files
lucky-cat/docs/knowledge vault/multimodal-voice-startup-upload-isolation.zh-CN.md
T

419 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 能继续上传,而不是永久被阻塞。