Baji_Rtc_Toy/docs/Rtc_AIavatar/RTC软退出方案_移植参考.md
Rdzleo b8a5fe958f feat(rtc-only): Phase 6 - RTC 空闲软休眠(B+C 双源 + 真退房 + 字幕提示 + 内存兜底)
按 GSD 框架 .planning/milestones/digital_human_rtc/phases/phase_06_idle_hibernate/
规划完成 Phase 6 软退出 RTC 机制。替代旧的"40s 硬重启退出"方案。

## 核心变更

### 1. 倒计时刷新(B+C 双源方案)

| 方案 | 监听源 | 实施位置 | 状态 |
|------|--------|---------|------|
| A 扬声器流 | I2S/PCM 输出 | application.cc audio output 3 处 | **宏关闭**(PHASE6_ENABLE_AUDIO_FALLBACK) |
| **B 字幕监听** | RTC subtitle 消息 | application.cc:1300 subtitle 分支 | **启用** |
| **C 智能体状态** | RTC conv_status 消息 | application.cc:1260 conv_status 分支 | **启用** |

复用现有 DIALOG_IDLE_COUNTDOWN_SECONDS=40 不新增常量。

### 2. 真退出 RTC 房间(释放 License)

- 新增 Protocol 基类虚函数 LeaveRoom(默认回退到 CloseAudioChannel)
- VolcRtcProtocol::LeaveRoom 覆写:volc_rtc_stop + volc_rtc_destroy
  - 火山官方文档明确:真退房必须 leaveRoom + destroyRTCEngine
  - CloseAudioChannel 只 stop 不够(真人仍在房间继续计费)
- 服务端 AI 任务在 180s 内自动清理(火山平台机制)

### 3. EnterIdleHibernate / WakeFromHibernate

EnterIdleHibernate 流程(严格顺序):
1. protocol_->LeaveRoom()                  # 真退房
2. codec->EnableInput/Output(false)        # 重置 codec 状态机
3. recorder_pipeline_close()
4. hibernating_.store(true)                # 关键:先设标志阻止 PowerSaveTimer
5. esp_pm_configure(light_sleep=false)     # 双保险禁用 Light Sleep
6. SetDeviceState(kDeviceStateIdle)
7. idle_cycles_++ + NVS 持久化
8. 字幕"已自动退出RTC对话,按BOOT键重新连接RTC"(5 次重试间隔 200ms)

WakeFromHibernate 流程:
1. 检查 idle_cycles_ >= 50 → 硬重启清理碎片(兜底)
2. 清空字幕
3. ToggleChatState → OpenAudioChannel → 自动重建 rtc_handle_
4. RTC 重新加入房间(实测 2-3s 完成)

### 4. CanEnterSleepMode 加 hibernating 检查

防止 hibernate 期间 PowerSaveTimer 触发 esp_pm_configure(light_sleep=true)
导致 I2C 总线进入低功耗 → 唤醒后 ES7210/ES8311 通信失败 abort。

### 5. Dialog Watchdog 触发动作改造

旧:esp_restart() 整机重启(黑屏 15-25s + WiFi 重连)
新:Schedule(EnterIdleHibernate) 软退房(不熄屏 + 字幕提示)

### 6. BOOT 唤醒走 WakeFromHibernate 路径

iot_button 回调中检测 IsHibernating(),派发到独立 task 执行
WakeFromHibernate(避免阻塞 esp_timer 任务,CLAUDE.md 经验)。

### 7. OpenAudioChannel 适配重建

LeaveRoom 销毁 rtc_handle_ 后,OpenAudioChannel 头部检测 NULL
触发 Start() 异步重建,轮询 5s 等待就绪。NVS 缓存 device_secret
所以重建通常 100ms 完成。

## 实测验证(用户协作)

| 阶段 | 时间 |
|------|------|
| 40s 触发软休眠 |  |
| LeaveRoom 真退房 |  "✓ 已真退出 RTC 房间(leaveRoom + destroyRTCEngine)" |
| 屏幕保持 + 字幕显示 |  "已自动退出RTC对话,按BOOT键重新连接RTC" |
| BOOT 按键唤醒 |  |
| RTC 实例重建 |  100ms |
| RTC 重新加入房间 |  2-3s |
| 连续 2 次软休眠+唤醒 |  无 abort/I2C 失败 |
| 时间对比 | 旧硬重启 15-25s → 软休眠 3-5s(省 80%) |

## 6 个关键踩坑修复(详见 HIBERNATE_REPORT.md)

1. codec 状态机未重置 → 唤醒后 I2C abort
2. PowerSaveTimer Light Sleep 干扰 I2C 总线
3. hibernating_ 设置时序错误
4. dynamic_cast 在 -fno-rtti 下编译失败 → 改基类虚函数
5. LeaveRoom 后 OpenAudioChannel 直接失败 → 加重建逻辑
6. 字幕 LVGL 锁竞争 → 推迟到最后 + 5 次重试

## 文档产出(同时提交)

- .planning/.../phase_06_idle_hibernate/PLAN.md(含实施变更记录 V1-V6)
- .planning/.../phase_06_idle_hibernate/HIBERNATE_REPORT.md(验证报告)
- .planning/.../ROADMAP.md(Phase 1-5  + Phase 6 进行中状态更新)
- docs/Rtc_AIavatar/数字人表情渲染方案_云端预渲染+BLE+OTA.md
  新增第 19 章 RTC 空闲倒计时方案选型与软退出(9 小节)
- docs/Rtc_AIavatar/RTC软退出方案_移植参考.md
  完整移植参考(10 章 + 3 附录,可移植到其他火山 RTC 项目)
- docs/Rtc_AIavatar/音频卡顿_全局资源分析.md
  全局资源分析 + 13 项优化建议(不改代码)
2026-05-13 17:28:36 +08:00

32 KiB
Raw Blame History

火山 RTC 软退出房间方案 — 完整移植参考

来源: 数字人 RTC 项目 Phase 6 实施总结2026-05-13 用途: 移植到其他基于火山 RTC SDK 的项目,替换"硬重启退出"为"软退出 + 快速恢复"。 关键收益: 唤醒时间 15-25s → 3-5s省 80% 时间;用户体验:黑屏长断 → 字幕提示常亮。

目录


1. 背景与动机

1.1 旧方案的问题

火山 RTC AI 对话项目原有的"空闲退出 RTC"机制使用 硬重启

// Dialog Watchdog 40s 无音频输出触发
Settings sys("system", true);
sys.SetInt("reboot_dlg_idle", 1);
sys.SetInt("reboot_origin", 1);
esp_restart();   // 整机重启

痛点

  1. 黑屏 15-25s 不可用WiFi 重连 + 应用初始化)
  2. NVS/RAM 状态丢失(音量、亮度、对话上下文等)
  3. 用户感知"设备重启",不流畅
  4. 屏幕 PWM 重新初始化引起冷启动闪烁

1.2 设计目标(软退出)

  • 真正释放 License(不消耗火山 RTC 计费资源)
  • 快速恢复(按 BOOT 后 ~3s 恢复对话)
  • 屏幕保持(字幕提示告知用户)
  • 状态保留(音量、亮度、对话历史等)
  • 防御长期运行内存碎片(兜底机制)

2. 倒计时刷新方案选型

2.1 火山 RTC 协议层向应用层暴露的事件类型

[INF|volc_rtc.c:475]message received channel=... binary=1
   ↓
DataCallback 内部按前缀分发:
├── "subv" 前缀subtitle 字幕消息STT/TTS 内容)
├── "ctrl" 前缀conv_status 状态机消息5 状态)
├── "tool" 前缀function_call 工具调用
├── "info" 前缀:通用信息
└── "conv" 前缀:会话级消息

应用层只需在 application.cctype 分发分支添加 1 行时间戳刷新。

2.2 三方案对比矩阵

维度 方案 A 扬声器流 方案 B 字幕监听 方案 C 智能体状态
监听源 I2S DMA / Opus PCM 输出 RTC subtitle 消息 RTC conv_status 消息
更新位置 Application::OnAudioOutput audio task application.cc:1300 subtitle 分支 application.cc:1260 conv_status 分支
触发频率 每 20ms50 Hz 每秒 5-15 次 每轮对话 4-5 次
CPU 增量 中(每秒 50 次 chrono::now 极低10ns × 5-15 最低10ns × 4-5
代码改动 0已实现 1 行 1 行
覆盖场景:用户开始说话 AI 还没回应时无刷新 ⚠️ 等 STT 出字幕1-3s 延迟) 立即触发 LISTENING
覆盖场景AI 思考期 无音频输出 ⚠️ 等字幕送达 THINKING 触发
覆盖场景AI 持续说话 持续 PCM 输出 流式字幕持续 ANSWERING 状态稳定不切换
覆盖场景AI 长回答(>40s

2.3 选型结论B + C 双源(不启用 A

单方案缺陷

  • 单用 A用户开始说话时不刷新 → 第 38s 说话被踢出
  • 单用 B字幕送达有 1-3s 延迟 → 用户开始说话与 AI 响应窗口空缺
  • 单用 CAI 长说话期间 ANSWERING 状态不切换 → 倒计时无刷新

最优组合 B+C

  • C 处理状态机切换(最早响应用户开始说话事件)
  • B 处理流式字幕(最稳定的对话进行中刷新)
  • A 关闭(避免每 20ms 时间戳更新的 CPU 消耗)

实施代码1+1 行改动):

// application.cc 在 type 分发分支添加

} else if (strcmp(type->valuestring, "conv_status") == 0) {
    auto status_val = cJSON_GetObjectItem(root, "status");
    if (status_val) {
        last_audible_output_time_ = std::chrono::steady_clock::now();  // 方案 C
        // ... 原有 emoji 切换
    }
}

} else if (strcmp(type->valuestring, "subtitle") == 0) {
    auto data_arr = cJSON_GetObjectItem(root, "data");
    if (data_arr && cJSON_IsArray(data_arr) && cJSON_GetArraySize(data_arr) > 0) {
        last_audible_output_time_ = std::chrono::steady_clock::now();  // 方案 B
        // ... 原有字幕解析
    }
}

2.4 火山 RTC conv_status 5 状态机详解

value 状态 触发时机
1 LISTENING 用户开始说话VAD 检测到人声)
2 THINKING ASR 识别完成LLM 推理中
3 ANSWERING TTS 开始AI 输出第一个音频帧)
4 INTERRUPTED 用户打断VAD 检测到用户在 AI 说话期间说话)
5 ANSWER_FINISH TTS 结束AI 完成本轮回答)

关键洞察:状态转换 1→2→3→5 是一轮对话的标准流程5 个事件刷新足以覆盖正常对话节奏。但 AI 长回答(>40s期间状态稳定在 3 ANSWERING这是为什么必须补 B


3. 软退出 RTC 房间机制

3.1 火山 SDK API 关系(关键澄清)

┌──────────────────────────────────────────────────────────┐
│  volc_rtc_stop()                                          │
│  ≈ 服务端 StopVoiceChat                                    │
│  仅 AI 智能体离开房间                                       │
│  真人客户端仍在房间,继续产生音视频费用 ❌                   │
└──────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────┐
│  volc_rtc_destroy()                                       │
│  = leaveRoom + destroyRTCEngine                            │
│  真人客户端离开房间 + 销毁本地实例                            │
│  License 资源释放 ✅                                         │
│  服务端 AI 任务在 180s 内自动清理(火山平台机制)              │
└──────────────────────────────────────────────────────────┘

火山官方原文StopVoiceChat 文档):

"StopVoiceChat 接口仅会使智能体离开房间,真人用户不会离开房间,仍会产生音视频费用。如需完整结束通话,客户端还需调用 RTC SDK 接口 leaveRoom 使真人用户离开房间,并调用 destroyRTCEngine 销毁引擎实例。"

3.2 默认 CloseAudioChannel 的问题

// 原代码VolcRtcProtocol::CloseAudioChannel
void VolcRtcProtocol::CloseAudioChannel() {
    if (is_connected_) {
        volc_rtc_stop(rtc_handle_);   // ❌ 只 stop不 destroy
        is_connected_ = false;
    }
    is_audio_channel_opened_ = false;
}

CloseAudioChannelstopdestroy → 真人仍在房间消耗 License。这是原项目"硬重启退出"设计的根本原因——只有重启才能真正释放。

3.3 新增 LeaveRoom 接口

// Protocol 基类 protocol.h
class Protocol {
public:
    virtual void CloseAudioChannel() = 0;
    // 新增:真退出 RTC 房间(释放 License默认回退到 CloseAudioChannel
    virtual void LeaveRoom() { CloseAudioChannel(); }
};

// VolcRtcProtocol override
void VolcRtcProtocol::LeaveRoom() {
    if (rtc_handle_) {
        if (is_connected_) {
            volc_rtc_stop(rtc_handle_);     // 停止媒体流
            is_connected_ = false;
        }
        volc_rtc_destroy(rtc_handle_);      // 真退房 + 销毁
        rtc_handle_ = nullptr;
        ESP_LOGI(TAG, "✓ 已真退出 RTC 房间leaveRoom + destroyRTCEngine");
    }
    is_audio_channel_opened_ = false;
    if (on_audio_channel_closed_) on_audio_channel_closed_();
}

为什么要在基类加虚函数而不是 dynamic_cast 项目通常用 -fno-rtti 优化掉 RTTIdynamic_cast 编译失败。基类虚函数 + override 是最干净的多态方式。

3.4 OpenAudioChannel 适配重建

LeaveRoom 销毁 rtc_handle_ 后,原有 OpenAudioChannel 直接 return false。需要适配

bool VolcRtcProtocol::OpenAudioChannel() {
    // 检测 rtc_handle_=NULL 且 iot_ready_ 为 true凭证已缓存
    if (!rtc_handle_ && iot_ready_) {
        ESP_LOGI(TAG, "RTC 实例不存在,触发重建...");
        iot_ready_ = false;       // 由 Start 任务重新置位
        Start();                  // 异步触发 volc_rtc_init 任务
        // 轮询等待 rtc_handle_ 就绪(最多 5 秒)
        int wait_ticks = 0;
        while (!rtc_handle_ && wait_ticks < 50) {
            vTaskDelay(pdMS_TO_TICKS(100));
            wait_ticks++;
        }
        if (!rtc_handle_) {
            ESP_LOGE(TAG, "RTC 重建超时");
            return false;
        }
        ESP_LOGI(TAG, "RTC 实例已重建(耗时 %d ms", wait_ticks * 100);
    }
    if (!rtc_handle_) {
        return false;
    }
    // ... 原有 volc_rtc_start 加入房间逻辑
}

实测:因为 NVS 缓存了 device_secret,无需重新走 HTTP 设备注册,volc_rtc_create 通常在 100ms 内完成。


4. 完整调用链与状态机

4.1 进入软休眠

Dialog Watchdog 任务40s 无音频输出 + 无 conv_status + 无字幕)
   ↓
触发 Application::EnterIdleHibernate()
   ↓
1. protocol_->LeaveRoom()
     ├─ volc_rtc_stop(rtc_handle_)
     └─ volc_rtc_destroy(rtc_handle_)
     → 真退房License 释放
   ↓
2. codec->EnableInput(false)
   codec->EnableOutput(false)
     → ESP-IDF esp_codec_dev_close重置 ES7210/ES8311 状态机)
   ↓
3. recorder_pipeline_close()
     → 释放录音管道(避免唤醒后重新打开冲突)
   ↓
4. hibernating_.store(true)
     → 阻止 PowerSaveTimer 进入 Light Sleep
   ↓
5. esp_pm_configure(light_sleep_enable=false)
     → 双保险:强制禁用 Light Sleep 保护 I2C 总线
   ↓
6. SetDeviceState(kDeviceStateIdle)
     → 设备状态切回 idle屏幕保持亮起
   ↓
7. idle_cycles_++  + NVS 持久化
     → 内存兜底计数
   ↓
8. display->SetChatMessage("system", "已自动退出RTC对话按BOOT键重新连接RTC")
     → 字幕持续显示5 次重试间隔 200ms避免 LVGL 锁竞争)

4.2 BOOT 按键唤醒

boot_button_.OnClick 回调iot_button 在 esp_timer 任务)
   ↓
检测 Application::IsHibernating() == true
   ↓
xTaskCreate("wake_hib", WakeFromHibernate, ...)
   → 派发到独立 task 避免阻塞 iot_button / esp_timer
   ↓
Application::WakeFromHibernate()
   ↓
1. 检查 idle_cycles_ >= 50
     → 累计达阈值 → ResetIdleCyclesNvs + esp_restart()(兜底清碎片)
   ↓
2. 清空字幕display->SetChatMessage("system", "")
   ↓
3. SetDeviceState(kDeviceStateIdle)(幂等)
   ↓
4. ToggleChatState()
     ↓
   OpenAudioChannel()
     ├─ 检测 rtc_handle_=NULL → Start() 异步重建
     ├─ 轮询等待 rtc_handle_ 就绪
     └─ volc_rtc_start(rtc_handle_, bot_id, iot_info_, ...)
     ↓
   等待 RTC 远程用户加入(火山 AI bot
     ↓
   音频通道打开,进入 Dialog 状态
   ↓
5. hibernating_.store(false)
   ↓
6. Dialog Watchdog 重新启动StartDialogWatchdog

4.3 内存兜底

// application.h
static constexpr int IDLE_CYCLES_REBOOT_THRESHOLD = 50;
int idle_cycles_ = 0;

// EnterIdleHibernate 中递增 + 持久化
void Application::SaveIdleCyclesToNvs() {
    Settings s("hibernate", true);
    s.SetInt("idle_cycles", idle_cycles_);
}

// WakeFromHibernate 中检查
if (idle_cycles_ >= IDLE_CYCLES_REBOOT_THRESHOLD) {
    ResetIdleCyclesNvs();
    pwm_set_brightness(80);   // 重启前先点亮
    Board::GetInstance().OnBeforeRestart();
    vTaskDelay(pdMS_TO_TICKS(500));
    esp_restart();
}

50 次按平均每次 5 分钟对话计算约覆盖 4 小时使用时长。NVS namespace hibernate 独立,不影响其他业务。


5. 实施清单

5.1 文件改动总览

文件 改动
main/protocols/protocol.h 基类加 virtual void LeaveRoom() { CloseAudioChannel(); }
main/protocols/volc_rtc_protocol.h/cc 新增 LeaveRoom() overridestop + destroy
main/protocols/volc_rtc_protocol.cc OpenAudioChannel 头部加重建逻辑
main/application.h 新增 hibernating_ / idle_cycles_ / EnterIdleHibernate / WakeFromHibernate 等接口
main/application.cc 新增 NVS 持久化 + EnterIdleHibernate / WakeFromHibernate + CanEnterSleepMode 加 hibernating 检查
main/application.cc conv_status 分支 + subtitle 分支各加 1 行刷新(方案 B+C
main/application.cc Dialog Watchdog 触发动作从 esp_restart 改为 Schedule(EnterIdleHibernate)
main/application.cc 顶部 // #define PHASE6_ENABLE_AUDIO_FALLBACK + 用 #ifdef 包裹方案 A 3 处更新
main/boards/<board>.cc BOOT 按键回调入口加 if (IsHibernating()) WakeFromHibernate()

5.2 关键代码模板

5.2.1 EnterIdleHibernate 完整实现

void Application::EnterIdleHibernate() {
    if (hibernating_.load()) return;
    ESP_LOGI(TAG, "🌙 进入空闲休眠:真退房 → 字幕提示(不熄屏)");

    auto display = Board::GetInstance().GetDisplay();

    // 1. 真退出 RTC 房间(释放 License
    if (protocol_) {
        protocol_->LeaveRoom();
    }

    // 2. 字幕显示推迟到最后

    // 3. 关闭 codec input/output 重置状态机(避免唤醒 abort
    auto codec = Board::GetInstance().GetAudioCodec();
    if (codec) {
        codec->EnableInput(false);
        codec->EnableOutput(false);
    }

    // 4. 关闭录音管道
    if (recorder_pipeline_) {
        recorder_pipeline_close(recorder_pipeline_);
        recorder_pipeline_ = nullptr;
    }

    // 5. 关键时序:先 hibernating_=true阻止 PowerSaveTimer 进入 Light Sleep
    hibernating_.store(true);

    // 6. 双保险:强制 esp_pm 禁用 Light Sleep
    esp_pm_config_t pm_config = {
        .max_freq_mhz = 240,
        .min_freq_mhz = 240,
        .light_sleep_enable = false,
    };
    esp_pm_configure(&pm_config);

    // 7. 设备状态切回 idle
    SetDeviceState(kDeviceStateIdle);

    // 8. 累计休眠次数NVS 持久化)
    idle_cycles_++;
    SaveIdleCyclesToNvs();
    ESP_LOGI(TAG, "✓ 已进入空闲休眠(累计第 %d 次)", idle_cycles_);

    // 9. 字幕显示最后做LVGL 锁竞争最少 + 5 次重试)
    const char* msg = "已自动退出RTC对话按BOOT键重新连接RTC";
    for (int i = 0; i < 5; i++) {
        vTaskDelay(pdMS_TO_TICKS(200));
        if (display) display->SetChatMessage("system", msg);
    }
}

5.2.2 WakeFromHibernate 完整实现

void Application::WakeFromHibernate() {
    if (!hibernating_.load()) return;
    ESP_LOGI(TAG, "☀ 从空闲休眠唤醒");

    // 内存兜底:累计 50 次硬重启清理碎片
    if (idle_cycles_ >= IDLE_CYCLES_REBOOT_THRESHOLD) {
        ResetIdleCyclesNvs();
        Board::GetInstance().OnBeforeRestart();
        vTaskDelay(pdMS_TO_TICKS(500));
        esp_restart();
        return;
    }

    // 清空 hibernate 字幕
    auto display = Board::GetInstance().GetDisplay();
    if (display) display->SetChatMessage("system", "");

    // 触发 RTC 重连
    if (device_state_ != kDeviceStateIdle) {
        SetDeviceState(kDeviceStateIdle);
    }
    ToggleChatState();   // 复用现有重连路径

    hibernating_.store(false);
    ESP_LOGI(TAG, "✓ 唤醒完成");
}

5.2.3 CanEnterSleepMode 关键改动

bool Application::CanEnterSleepMode() {
    if (device_state_ != kDeviceStateIdle) return false;
    if (protocol_ && protocol_->IsAudioChannelOpened()) return false;

    // 关键hibernate 期间禁用 PowerSaveTimer 的 Light Sleep
    // 否则 esp_pm_configure(light_sleep_enable=true) 让 I2C 进入低功耗
    // 唤醒后 ES7210/ES8311 通信失败导致 ESP_ERROR_CHECK abort
    if (hibernating_.load()) return false;

    return true;
}

5.2.4 Dialog Watchdog 触发动作改造

// 原代码:写 NVS + esp_restart()
// 新代码:
if (remaining <= 0) {
    ESP_LOGI(TAG, "Dialog watchdog 触发:%ds → 软退房", (int)elapsed);
    app->dialog_watchdog_running_ = false;
    app->Schedule([app]() {
        app->EnterIdleHibernate();
    });
    break;  // 退出 while 循环
}

5.2.5 BOOT 按键唤醒board 文件)

boot_button_.OnClick([this]() {
    // 防抖、配网模式检查等 ... 之前的逻辑

    // 优先处理 hibernate 唤醒
    auto &app = Application::GetInstance();
    if (app.IsHibernating()) {
        ESP_LOGI(TAG, "🔵 BOOT in hibernate → 唤醒");
        xTaskCreate([](void* arg) {
            Application::GetInstance().WakeFromHibernate();
            vTaskDelete(NULL);
        }, "wake_hib", 4096, NULL, 5, NULL);
        return;
    }

    // 非 hibernate 状态 → 走原有 ToggleChatState 路径
});

6. 6 个关键踩坑与修复经验

6.1 坑 1: codec 状态机未重置 → 唤醒后 I2C abort

现象BOOT 唤醒后日志显示

Adev_Codec: Input already open
E (xxxxx) I2C_If: Fail to write to dev 30   ← ES8311 (0x18=write 0x30)
E (xxxxx) I2C_If: Fail to read from dev 80  ← ES7210 (0x40=read 0x80)
abort() was called at PC 0x40386e57
Backtrace: ... BoxAudioCodec::EnableInput at box_audio_codec.cc:265

根因LeaveRoom 只销毁 RTC 实例,但 esp_codec_dev 内部状态仍是 "open"。唤醒后 EnableInput(true) 看到 codec_dev 已 open → 直接调用 set_in_channel_gain 而非完整 esp_codec_dev_open 流程 → I2C 通信走异常路径失败。

修复EnterIdleHibernate 显式重置:

codec->EnableInput(false);    // 触发 esp_codec_dev_close
codec->EnableOutput(false);

6.2 坑 2: PowerSaveTimer Light Sleep 干扰 I2C 总线

现象:日志显示 hibernate 期间出现

I (xxxxx) Airhub1: 🔋 进入低功耗模式CPU降频、Light Sleep启用、功放关闭

然后 BOOT 唤醒后 codec I2C 失败 abort同坑 1 的现象)。

根因PowerSaveTimer::PowerSaveCheck 在 idle 状态 10s 后触发:

esp_pm_config_t pm_config = {
    .max_freq_mhz = 240,
    .min_freq_mhz = 40,
    .light_sleep_enable = true,   // ⚠️
};
esp_pm_configure(&pm_config);

ESP32-S3 进入 Light Sleep 后,I2C 控制器外设进入低功耗状态,寄存器/锁可能丢失。唤醒后第一次 I2C 操作失败。

修复(双保险)

  1. CanEnterSleepMode()if (hibernating_.load()) return false,让 PowerSaveTimer 不进入 sleep mode
  2. EnterIdleHibernate 中 esp_pm_configure(light_sleep_enable=false) 强制覆盖

6.3 坑 3: hibernating_ 设置时序错误

现象:即使 CanEnterSleepMode 已加 hibernating 检查hibernate 后仍有 Light Sleep 触发。

根因EnterIdleHibernate 顺序如果是

SetDeviceState(kDeviceStateIdle);   // 此时 device_state=idlehibernating_=false
                                     // → CanEnterSleepMode 返回 true
                                     // → PowerSaveTimer 可能立即触发 Light Sleep
hibernating_.store(true);           // 设置太晚

修复:必须先设 hibernating_=true,再 SetDeviceState(kDeviceStateIdle)

hibernating_.store(true);            // ✅ 先设标志
esp_pm_configure(light_sleep=false); // ✅ 强制禁用
SetDeviceState(kDeviceStateIdle);    // ✅ 此时 CanEnterSleepMode 因 hibernating_ 返回 false

6.4 坑 4: dynamic_cast 在 -fno-rtti 下编译失败

现象

error: 'dynamic_cast' not permitted with '-fno-rtti'
auto* volc = dynamic_cast<VolcRtcProtocol*>(protocol_.get());

ESP-IDF 项目通常用 -fno-rtti 优化二进制大小,禁用 RTTI。

修复:在 Protocol 基类加虚函数:

class Protocol {
public:
    virtual void LeaveRoom() { CloseAudioChannel(); }
};

VolcRtcProtocol::LeaveRoom() override 实现 stop + destroy。Application 直接 protocol_->LeaveRoom() 多态调用。

6.5 坑 5: LeaveRoom 后 OpenAudioChannel 直接失败

现象:唤醒后日志

I (xxxxx) VolcRtcProtocol: 无法打开音频通道RTC句柄未准备就绪

根因:原 OpenAudioChannel 检查 if (!rtc_handle_) return false 直接返回。

修复:检测 NULL 时触发 Start() 异步重建并轮询:

if (!rtc_handle_ && iot_ready_) {
    iot_ready_ = false;
    Start();
    int wait = 0;
    while (!rtc_handle_ && wait < 50) {
        vTaskDelay(pdMS_TO_TICKS(100));
        wait++;
    }
    if (!rtc_handle_) return false;
}

NVS 已缓存 device_secret,无需重新走 HTTP 设备注册,重建通常 100ms 完成。

6.6 坑 6: 字幕 LVGL 锁竞争超时

现象:日志显示

W (xxxxx) AI_CHAT_UI: LVGL锁超时跳过字幕更新

字幕未显示。

根因EnterIdleHibernate 中 SetChatMessage 立即调用,但 LeaveRoom 期间 LVGL 锁被 GIF 解码任务竞争500ms 超时。

修复

  1. 字幕调用放在 hibernate 流程最后LeaveRoom/codec/recorder 都完成后)
  2. 5 次重试间隔 200ms共 1 秒等待 LVGL 渲染完成)
for (int i = 0; i < 5; i++) {
    vTaskDelay(pdMS_TO_TICKS(200));
    if (display) display->SetChatMessage("system", msg);
}

ai_chat_set_chat_message 内部有 last_content 去重缓存,锁超时时不更新缓存,下次重试相同内容仍会进入 lvgl_port_lock 路径,重试有效


7. 移植到其他项目的最小改动清单

7.1 前置条件检查

检查项 是否满足
项目使用火山 RTC SDK (volc_engine_rtc_lite) 必须
Protocol / VolcRtcProtocol 类结构存在 必须
last_audible_output_time_ + Dialog Watchdog 机制存在 必须(如无则需新增)
Application 类有 Schedule 调度机制 必须
项目有 PowerSaveTimer 影响坑 2 的修复方案
Board 有 audio_codec / GetAudioCodec 接口 必须
LVGL 锁机制(lvgl_port_lock 影响坑 6 的修复方案

7.2 移植步骤(按顺序)

  1. LeaveRoom 虚函数 + 实现

    • protocol.hvirtual void LeaveRoom() { CloseAudioChannel(); }
    • volc_rtc_protocol.hvoid LeaveRoom() override;
    • volc_rtc_protocol.cc 实现 stop + destroy
  2. OpenAudioChannel 加重建逻辑

    • 检测 rtc_handle_=NULL && iot_ready_ 时触发 Start + 轮询
  3. Application 加 hibernate 状态

    • hibernating_ (std::atomic)
    • idle_cycles_ + NVS 持久化函数
  4. CanEnterSleepMode 加 hibernating 检查

  5. 新增 EnterIdleHibernate / WakeFromHibernate 方法

    • 严格按本文 §5.2 模板顺序
  6. 方案 B + C 双源刷新

    • subtitle 分支加 1 行
    • conv_status 分支加 1 行
  7. Dialog Watchdog 触发动作改造

    • esp_restart()Schedule(EnterIdleHibernate)
  8. BOOT 按键回调加唤醒分支

    • if (IsHibernating()) WakeFromHibernate() 派发到独立 task
  9. 保留方案 A 用宏关闭(不删源码)

    • 顶部加 // #define PHASE6_ENABLE_AUDIO_FALLBACK
    • #ifdef 包裹 audio output 路径的 3 处 last_audible_output_time_ 更新

7.3 项目无 PowerSaveTimer 的情况

如果目标项目没有 PowerSaveTimer

  • 坑 2 的 CanEnterSleepMode 检查可省略
  • 但仍建议保留 esp_pm_configure(light_sleep=false) 作为 ESP-IDF 全局保护

7.4 项目用 Modem SleepWiFi PSM的情况

WiFi PSM 不影响 I2C 总线,可正常使用。 但建议 hibernate 期间通过 Board::SetPowerSaveMode(false) 暂时禁用,避免 WiFi DTIM 与 RTC 重连冲突。


8. 时间对比与性能数据

8.1 唤醒时间分解(实测)

阶段 硬重启退出 软退出 差异
设备启动bootloader + 应用初始化) 2-3s 0s -3s
WiFi 重连NVS 凭据连接 AP 10-15s 0s保持连接 -15s
RTC 实例创建(volc_rtc_create ~100ms ~100ms
HTTP GetRTCConfig 获取 token ~1-3s ~1-3s
加入房间(byte_rtc_join_room ~300ms ~300ms
远程 AI 加入(bot_message ~0.5-2s ~0.5-2s
总计 ~15-25s ~3-5s -80%

8.2 资源消耗

指标 数值
方案 B 字幕监听 CPU 增量 ~10ns/字幕 × 10/s = 100ns/s<0.001%
方案 C 状态监听 CPU 增量 ~10ns/事件 × 5/对话 ≈ 0
hibernating_ 检查 CPU 增量 0每次 PowerSaveCheck 1 次原子加载)
内存增量 hibernating_ (1B) + idle_cycles_ (4B) + last_content[256] = ~261B
NVS 增量 namespace hibernate 1 个 int32 key

8.3 状态保留对比

状态 硬重启 软退出
WiFi 凭据 NVS 保留
设备配置(音量/亮度等) NVS 保留
火山 RTC device_secret NVS 缓存
RAM 中的对话历史 清空 保留
LVGL UI 状态 重新加载 保留
数字人 GIF 解码缓存 重新加载 保留
OTA 升级状态 otadata 保留

9. 验证清单

9.1 功能验证

  • 40s 无活动自动进入软休眠(字幕显示 + 屏幕保持)
  • 用户说话 38s 不被踢出(方案 C LISTENING 触发)
  • AI 长回答 50s 不被踢出(方案 B 字幕持续刷新)
  • BOOT 按键唤醒 → 3-5s 内 RTC 重连完成
  • 唤醒后正常进行新一轮对话
  • 连续 5+ 次软休眠 + 唤醒循环无异常

9.2 异常验证

  • 唤醒后无 Fail to write/read I2C 错误
  • abort() was called panic
  • 进入低功耗模式Light Sleep启用 日志hibernate 期间)
  • 字幕"已自动退出RTC对话..."实际显示在屏幕上
  • NVS hibernate/idle_cycles 正确累计

9.3 边界验证

  • WiFi 断开期间 BOOT 唤醒不崩溃(应留在 idle 状态)
  • 软休眠期间 BLE 配网请求不冲突
  • 累计 idle_cycles_ ≥ 50 后下次 BOOT 唤醒触发硬重启

9.4 License 验证(可选)

  • 火山 RTC 控制台查看会话时长(确认软退出后不再计费)
  • volc_rtc.c:475 message received 日志统计字幕流是否完全停止

10. 火山 RTC SDK 关键 API 速查

10.1 客户端 SDK APIcomponents/common/inc/volc_rtc.h

typedef void* volc_rtc_t;

// 创建 RTC 实例(应用启动时调用一次)
volc_rtc_t volc_rtc_create(const char* appid, void* context,
                           cJSON* p_config,
                           volc_msg_cb message_callback,
                           volc_data_cb data_callback);

// 销毁 RTC 实例 = leaveRoom + destroyRTCEngine ← 真退房 + 释放 License
void volc_rtc_destroy(volc_rtc_t rtc);

// 启动 AI 任务 + 加入房间(≈ 服务端 StartVoiceChat
int volc_rtc_start(volc_rtc_t rtc, const char* bot_id,
                   volc_iot_info_t* iot_info, const char* extra_params);

// 停止 AI 任务(≈ 服务端 StopVoiceChat仅 AI 离开房间)
int volc_rtc_stop(volc_rtc_t rtc);

// 发送数据
int volc_rtc_send(volc_rtc_t rtc, const void* data, int size, volc_data_info_t* info);

// 中断 AI 说话
int volc_rtc_interrupt(volc_rtc_t rtc);

10.2 服务端 HTTP API

API 用途 客户端是否必须配合
StartVoiceChat 启动 AI 任务(运营/管理后台用) volc_rtc_start 内部已调用
StopVoiceChat 停止 AI 任务(运营强制下线) 客户端必须额外调 leaveRoom + destroyRTCEngine
UpdateVoiceChat 修改 AI 任务参数 不需要

结论:设备端 idle 退出场景只用客户端 SDK 即可,无需调用服务端 HTTP API。客户端 destroy 后服务端 AI 任务在 180s 内自动清理。

10.3 消息类型分类

前缀 类型 应用层处理位置
subv 字幕消息STT/TTS 内容) type=="subtitle" 分支
ctrl 控制消息(含 conv_status type=="conv_status" 分支
tool 工具调用function call type=="response.function_call_arguments.done" 分支
info 通用信息status/error 通用 type 分发
conv 会话级消息 通用 type 分发

附录 A: 配合修改的其他细节

A.1 sleep_mgr 模块(如有)

如果项目有独立的 sleep_mgr.c(如电子吧唧/玩具类项目):

  • AI 模式建议完全不使用它(其设计为吧唧/相册类应用场景)
  • 用本文档的 hibernate 机制替代

A.2 PowerSaveTimer.OnExitSleepMode 回调

如果项目 PowerSaveTimer 的 OnExitSleepMode 回调会重新打开 codec

  • 检查回调中是否有 codec->EnableOutput(true)
  • 如有hibernate 期间这个回调不应触发(因为 PowerSaveTimer 被 hibernating_ 阻止进入 sleep
  • 若仍误触发,加 if (Application::IsHibernating()) return 守卫

A.3 OTA 与软休眠协同

软休眠期间收到 OTA 升级请求:

  • 建议在 OTA 触发前先调用 WakeFromHibernate()
  • 避免 OTA 期间 RTC 实例处于 destroyed 状态导致协议层混乱

A.4 BLE 配网与软休眠协同

软休眠期间 BLE 配网请求:

  • BLE 不受 hibernating_ 影响
  • 但配网完成后建议触发 WakeFromHibernate()(如果设备处于 hibernate

附录 B: 调试日志关键标记

实施后可通过 grep 这些日志验证流程:

✓ 已真退出 RTC 房间leaveRoom + destroyRTCEngine   # LeaveRoom 成功
🌙 进入空闲休眠:真退房 → 字幕提示(不熄屏)            # EnterIdleHibernate 入口
🛡 累计休眠 N 次(阈值 50下次唤醒触发硬重启          # 兜底机制即将触发
☀ 从空闲休眠唤醒                                       # WakeFromHibernate 入口
🔵 BOOT in hibernate → 唤醒                            # BOOT 按键路径
Phase 6: RTC 实例不存在,触发重建...                    # OpenAudioChannel 重建
Phase 6: RTC 实例已重建(耗时 X ms                    # 重建完成
RTC远程用户加入                                         # AI 加入房间
EnterIdleHibernate: 已强制禁用 Light Sleep保护 I2C 总线) # 双保险生效
🕒 conv_status=X 刷新对话活跃时间                       # 方案 C 触发
🕒 字幕刷新对话活跃时间DEBUG 级别)                   # 方案 B 触发
LVGL锁超时跳过字幕更新                                # 字幕重试机制提示

附录 C: 与本项目的源码映射

本文档章节 源码位置
§3.3 LeaveRoom main/protocols/volc_rtc_protocol.cc:409
§3.4 OpenAudioChannel 重建 main/protocols/volc_rtc_protocol.cc:336
§5.2.1 EnterIdleHibernate main/application.cc:4377
§5.2.2 WakeFromHibernate main/application.cc:4440
§5.2.3 CanEnterSleepMode main/application.cc:3595
§5.2.4 Dialog Watchdog main/application.cc:2057
§5.2.5 BOOT 唤醒 main/boards/movecall-moji-esp32s3/movecall_moji_esp32s3.cc:570
方案 B subtitle 刷新 main/application.cc:1300
方案 C conv_status 刷新 main/application.cc:1260
方案 A 宏 + ifdef 包裹 main/application.cc:45-48 + application.cc:2221/2254/2266

文档版本: v1.0 (2026-05-13) 适用项目: 任何基于火山 RTC SDK (volc_engine_rtc_lite) 的 ESP32 项目 实施参考: 数字人 RTC 项目 Phase 6.planning/milestones/digital_human_rtc/phases/phase_06_idle_hibernate/