按 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 项优化建议(不改代码)
32 KiB
火山 RTC 软退出房间方案 — 完整移植参考
来源: 数字人 RTC 项目 Phase 6 实施总结(2026-05-13) 用途: 移植到其他基于火山 RTC SDK 的项目,替换"硬重启退出"为"软退出 + 快速恢复"。 关键收益: 唤醒时间 15-25s → 3-5s,省 80% 时间;用户体验:黑屏长断 → 字幕提示常亮。
目录
- 1. 背景与动机
- 2. 倒计时刷新方案选型(A vs B vs C)
- 3. 软退出 RTC 房间机制
- 4. 完整调用链与状态机
- 5. 实施清单(步骤化)
- 6. 6 个关键踩坑与修复经验
- 7. 移植到其他项目的最小改动清单
- 8. 时间对比与性能数据
- 9. 验证清单
- 10. 火山 RTC SDK 关键 API 速查
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(); // 整机重启
痛点:
- ❌ 黑屏 15-25s 不可用(WiFi 重连 + 应用初始化)
- ❌ NVS/RAM 状态丢失(音量、亮度、对话上下文等)
- ❌ 用户感知"设备重启",不流畅
- ❌ 屏幕 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.cc 的 type 分发分支添加 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 分支 |
| 触发频率 | 每 20ms(50 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 响应窗口空缺
- 单用 C:AI 长说话期间 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;
}
CloseAudioChannel 只 stop 不 destroy → 真人仍在房间消耗 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 优化掉 RTTI,dynamic_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() override:stop + 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 操作失败。
修复(双保险):
CanEnterSleepMode()加if (hibernating_.load()) return false,让 PowerSaveTimer 不进入 sleep mode- EnterIdleHibernate 中
esp_pm_configure(light_sleep_enable=false)强制覆盖
6.3 坑 3: hibernating_ 设置时序错误
现象:即使 CanEnterSleepMode 已加 hibernating 检查,hibernate 后仍有 Light Sleep 触发。
根因:EnterIdleHibernate 顺序如果是
SetDeviceState(kDeviceStateIdle); // 此时 device_state=idle,hibernating_=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 超时。
修复:
- 字幕调用放在 hibernate 流程最后(LeaveRoom/codec/recorder 都完成后)
- 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 移植步骤(按顺序)
-
加
LeaveRoom虚函数 + 实现protocol.h加virtual void LeaveRoom() { CloseAudioChannel(); }volc_rtc_protocol.h加void LeaveRoom() override;volc_rtc_protocol.cc实现 stop + destroy
-
OpenAudioChannel加重建逻辑- 检测
rtc_handle_=NULL && iot_ready_时触发 Start + 轮询
- 检测
-
Application 加 hibernate 状态
hibernating_(std::atomic)idle_cycles_+ NVS 持久化函数
-
CanEnterSleepMode加 hibernating 检查 -
新增
EnterIdleHibernate/WakeFromHibernate方法- 严格按本文 §5.2 模板顺序
-
方案 B + C 双源刷新
- subtitle 分支加 1 行
- conv_status 分支加 1 行
-
Dialog Watchdog 触发动作改造
esp_restart()→Schedule(EnterIdleHibernate)
-
BOOT 按键回调加唤醒分支
if (IsHibernating()) WakeFromHibernate()派发到独立 task
-
保留方案 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 Sleep(WiFi 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/readI2C 错误 - 无
abort() was calledpanic - 无
进入低功耗模式: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 API(components/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/)