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

13 KiB
Raw Blame History

OTA 后首次语音启动与后台上传隔离修复说明

历史实现记录:文中远程日志及 LogFS 上传路径已从当前固件移除,相关描述不代表现行行为。

1. 提交信息

609fe97 fix(multimodal): protect voice startup from background uploads

本提交解决 OTA 加入后容易出现的以下现象:

  1. 开机后第一次说“喵掌柜”,设备识别到了唤醒词,但没有进入录音和回答流程。
  2. 第二次唤醒能够成功,但第一次正式回答等待时间明显偏长。
  3. 后续多轮对话又恢复正常。
  4. 第一次失败后,旧 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. 解决成果

  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 能继续上传,而不是永久被阻塞。