419 lines
13 KiB
Markdown
419 lines
13 KiB
Markdown
# 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 能继续上传,而不是永久被阻塞。
|