按 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 项优化建议(不改代码)
15 KiB
ROADMAP — 数字人 RTC 项目
7 个阶段,按依赖关系串行。每个阶段产生原子 commit,可独立 revert。
阶段总览
Phase 1 (Kconfig 屏蔽吧唧) ──┐
├─→ Phase 3 (GIF 资源准备) ──┐
Phase 2 (分区表调整) ──┘ │
├─→ Phase 4 (情绪→GIF 映射)
│
└─→ Phase 5 (字幕恢复)
│
Phase 6 (RTC 空闲超时联动)
│
Phase 7 (集成测试 + 推送)
Phase 1: Kconfig 屏蔽电子吧唧模式 ⚠️ 结构性变更(条件编译,不删源码)
目标:通过 Kconfig 开关 + CMakeLists 条件编译 + 调用点 #ifdef 保护,让吧唧模式代码不进固件但保留在仓库中。Rtc_AIavatar 分支默认 CONFIG_BAJI_BADGE_MODE=n,main 分支默认 =y 保持双模式可恢复。
1.1 新增 Kconfig 开关
修改 main/Kconfig.projbuild,新增:
menu "Baji RTC Toy Configuration"
config BAJI_BADGE_MODE
bool "Enable electronic badge mode (双模式电子吧唧)"
default n
help
启用电子吧唧模式(图片浏览、APP传图、设备间分享、KEY2按键等)。
关闭后仅保留 AI 对话 + 数字人 RTC 功能,节省固件体积。
源代码不会被删除,可随时重新启用。
endmenu
并在 Rtc_AIavatar 分支的 sdkconfig.defaults 中追加:
CONFIG_BAJI_BADGE_MODE=n
1.2 CMakeLists.txt 条件化
修改 main/CMakeLists.txt,将吧唧专属 srcs 包裹在条件块中:
# AI 对话 + 数字人 RTC 核心(始终编译)
set(srcs
"main.cc"
"application.cc"
"ota.cc"
"bluetooth_provisioning.cc"
# ... RTC 协议、Opus、I2S、LCD、字幕等
"dzbj/lcd.c"
"dzbj/ai_chat_ui.c"
"dzbj/bg_gif_demo.c"
"dzbj/dual_gif_demo.c"
"dzbj/sprite_demo.c"
)
# 电子吧唧模式专属(条件编译)
if(CONFIG_BAJI_BADGE_MODE)
list(APPEND srcs
"dzbj/device_mode.c"
"dzbj/dzbj_ble.c"
"dzbj/ble_transfer.c"
"dzbj/dzbj_button.c" # KEY2 部分,BOOT 单键回调在公共模块
"dzbj/pages.c"
"dzbj/fatfs.c"
"dzbj/pages_pwm.c"
"dzbj/dzbj_battery.c"
"dzbj/dzbj_init.c"
"dzbj/sleep_mgr.c"
# UI Screens
"ui/screens/ui_ScreenHome.c"
"ui/screens/ui_ScreenImg.c"
"ui/screens/ui_ScreenSet.c"
"ui/screens/ui_ScreenPeiwang.c"
"ui/screens/ui_ScreenImageShar.c"
"ui/screens/ui_ScreenImageReception.c"
"ui/screens/ui_ScreenSharing.c"
"ui/screens/ui_ScreenReceiving.c"
"ui/screens/ui_ScreenUpdate.c"
)
endif()
1.3 调用点 #ifdef 保护
在所有引用吧唧符号的位置加保护,源代码不删除:
-
main/application.cc:#ifdef CONFIG_BAJI_BADGE_MODE #include "dzbj/device_mode.h" #endif void Application::Start() { // ... 公共代码 ... #ifdef CONFIG_BAJI_BADGE_MODE if (device_mode_get() == MODE_BADGE) { InitBadgeMode(); return; } #endif InitAiMode(); } -
main/boards/movecall-moji-esp32s3/movecall_moji_esp32s3.cc:所有 dzbj header include、初始化、BOOT+KEY2 组合键回调全部用#ifdef CONFIG_BAJI_BADGE_MODE包裹 -
main/dzbj/dzbj_button.c:BOOT 按键回调代码本身保留可编译(公共功能),KEY2 处理代码块用#ifdef CONFIG_BAJI_BADGE_MODE包裹 -
main/dzbj/ai_chat_ui.c:清理对吧唧界面的跳转(用#ifdef保护,不删代码)
1.4 头文件 stub 处理
对于条件编译后未链接的吧唧模块,其他保留模块若有引用:
- 头文件本身仍存在(包含 prototype)
- 若调用点未用
#ifdef保护就会链接报错 - 解决:在调用点全部加
#ifdef(首选);或在 .h 内提供 stub 实现(次选)
1.5 任务清单
- 修改
main/Kconfig.projbuild新增CONFIG_BAJI_BADGE_MODE开关 - 修改
main/CMakeLists.txt把吧唧 srcs 包裹在if(CONFIG_BAJI_BADGE_MODE)内 - 修改
main/application.cc加#ifdef保护 - 修改
main/boards/movecall-moji-esp32s3/movecall_moji_esp32s3.cc加#ifdef保护 - 修改
main/dzbj/ai_chat_ui.c跳转点加#ifdef保护 - 修改
main/dzbj/dzbj_button.cKEY2 代码块加#ifdef保护 - 修改
main/dzbj/sleep_mgr.c整体用#ifdef CONFIG_BAJI_BADGE_MODE包裹(Phase 6 改造为 RTC 联动版) - 修改
sdkconfig.defaults(或sdkconfig.ci)确保 Rtc_AIavatar 默认CONFIG_BAJI_BADGE_MODE=n - 产出
.planning/milestones/digital_human_rtc/BADGE_MODE_ISOLATION_MAP.md,列出所有#ifdef边界位置
完成标志:
- ✅
CONFIG_BAJI_BADGE_MODE=n时idf.py build编译通过 - ✅
CONFIG_BAJI_BADGE_MODE=y时idf.py build也编译通过(G7 验收,可恢复双模式) - ✅ 烧录
CONFIG_BAJI_BADGE_MODE=n版本:开机直接进入 AI 对话界面(无模式选择) - ✅
main/dzbj/下所有源文件仍然存在(未删除) - ✅
BADGE_MODE_ISOLATION_MAP.md已生成
风险点:
- C++ 类成员函数无法用
#ifdef完全屏蔽(如 Application 类的吧唧成员变量),需要把成员变量也用#ifdef包裹 - 头文件相互 include 可能导致循环
#ifdef,必要时改用 forward declaration
产出 commit:feat(kconfig): 引入 CONFIG_BAJI_BADGE_MODE 开关 - 吧唧模式可条件编译屏蔽
Phase 2: 分区表调整
目标:扩容 SPIFFS 到 6MB 装下 3 个 GIF + 背景图。
任务:
- 修改
partitions.csv:
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x4000
otadata, data, ota, 0xD000, 0x2000
phy_init, data, phy, 0xF000, 0x1000
ota_0, app, ota_0, 0x10000, 0x400000 # 4MB(从 6.5MB 缩)
ota_1, app, ota_1, 0x410000, 0x400000 # 4MB
storage, data, spiffs, 0x810000, 0x600000 # 6MB(从 2.875MB 扩)
# 删除 64KB model 分区(暂未用)
- 验证:
idf.py partition-table总和 = 16MB 减去引导区 - 烧录后:
heap_caps_get_free_size(MALLOC_CAP_SPIRAM)不变,SPIFFS 显示 6MB
完成标志:
- ✅
idf.py partition-table总和不超 Flash 大小 - ✅ 编译后固件 .bin < 4MB(确认应用分区够装)
- ✅ 烧录后
esp_spiffs_info("storage", &total, &used)返回 total ≈ 6MB
产出 commit:chore(partitions): app 双 OTA 4MB + SPIFFS 6MB(为数字人 GIF 扩容)
Phase 3: GIF 资源准备
目标:准备 m03/m06/m07 三个 hiyori GIF,gifsicle 处理 + 居中裁剪。
任务:
- 源 GIF 位置:
docs/Rtc_AIavatar/Resources/hiyori_free_zh/Export/m{03,06,07}/hiyori_m{03,06,07}.gif - 用 PIL 遍历所有帧找 bbox(吸取 PoC 经验):
# tools/sprite_poc/prepare_hiyori_3gifs.py(新写)
# 对每个 GIF:
# 1. PIL ImageSequence.Iterator 找全帧 bbox
# 2. 计算裁剪框(含全部动作幅度,宽 240,高从脚底向上 320)
# 3. gifsicle --crop X,Y+WxH --resize 240x320 -O3 --colors 256
# 4. 输出到 spiffs_image/hiyori_m{03,06,07}.gif
- 验证每个 GIF:
- 文件大小(合计 ≤ 5.5MB 留余量)
gifsicle --info检查每帧有 transparent 索引- 设备烧录单独测试每个 GIF(临时改
bg_gif_demo_start参数)
完成标志:
- ✅
spiffs_image/hiyori_m{03,06,07}.gif三个文件存在 - ✅ 每个文件 < 2MB,三个合计 < 5.5MB
- ✅ 设备烧录后三个 GIF 都能透明显示,无锯齿
产出 commit:feat(assets): 准备 hiyori 三表情 GIF(m03/m06/m07)+ Python 处理脚本
Phase 4: 情绪 → GIF 映射
目标:22 种情绪标签 → 3 个 GIF 的映射表,RTC 字幕情绪自动切换 GIF。
任务:
- 在
main/dzbj/ai_chat_ui.c设计映射表:
typedef struct {
const char *emotion; // RTC 协议情绪标签
const char *gif_path; // SPIFFS GIF 路径
} emotion_gif_map_t;
static const emotion_gif_map_t emotion_gif_table[] = {
// 默认/积极 → m06 轻松
{"neutral", "/spiflash/hiyori_m06.gif"},
{"happy", "/spiflash/hiyori_m06.gif"},
{"laughing", "/spiflash/hiyori_m06.gif"},
{"funny", "/spiflash/hiyori_m06.gif"},
{"cool", "/spiflash/hiyori_m06.gif"},
{"loving", "/spiflash/hiyori_m06.gif"},
{"relaxed", "/spiflash/hiyori_m06.gif"},
{"delicious", "/spiflash/hiyori_m06.gif"},
{"silly", "/spiflash/hiyori_m06.gif"},
{"winking", "/spiflash/hiyori_m06.gif"},
{"kissy", "/spiflash/hiyori_m06.gif"},
{"confident", "/spiflash/hiyori_m06.gif"},
// 思考/疲倦 → m07 睡眠
{"sleepy", "/spiflash/hiyori_m07.gif"},
{"thinking", "/spiflash/hiyori_m07.gif"},
{"confused", "/spiflash/hiyori_m07.gif"},
{"embarrassed","/spiflash/hiyori_m07.gif"},
// 负面/严肃 → m03 中等
{"sad", "/spiflash/hiyori_m03.gif"},
{"crying", "/spiflash/hiyori_m03.gif"},
{"angry", "/spiflash/hiyori_m03.gif"},
{"surprised", "/spiflash/hiyori_m03.gif"},
{"shocked", "/spiflash/hiyori_m03.gif"},
{"serious", "/spiflash/hiyori_m03.gif"},
};
- 实现
ai_chat_set_emotion(const char *emotion):- 查表 → 调用
bg_gif_demo_switch_gif(path) - 静态变量
last_gif_path去重避免重复加载
- 查表 → 调用
- 在
bg_gif_demo.c加switch_gif()接口:- 释放旧 GIF PSRAM
- 加载新 GIF
lv_gif_set_src(g_gif_obj, &g_gif_dsc)- 重新设置定时器周期 20ms(避免恢复默认 10ms)
- 在
application.cc/volc_rtc_protocol.cc字幕回调中调用ai_chat_set_emotion() - 字幕到达时立即触发(不等 is_final),用
last_subtitle_emotion去重
完成标志:
- ✅ AI 回复"(happy)你好"时 GIF 切到 m06
- ✅ AI 回复"(sad)抱歉"时 GIF 切到 m03
- ✅ 切换间无内存泄漏(连续切 50 次 PSRAM 不持续减少)
产出 commit:feat(emotion): 情绪标签 → hiyori GIF 映射 + bg_gif_demo 切换接口
Phase 5: RTC 字幕恢复
目标:屏幕底部半透明字幕显示,不遮挡数字人。
任务:
- 修改
main/dzbj/ai_chat_ui.c:- 第 165 行删除
lv_obj_add_flag(chat_label, LV_OBJ_FLAG_HIDDEN) - 第 342 行删除
if (USE_BG_GIF_POC) return - 调整
chat_label创建参数:lv_obj_align(chat_label, LV_ALIGN_BOTTOM_MID, 0, -70)- 宽度 300px,wrap 模式
- 字体
font_puhui_20_4,颜色 0xFFFFFF(白色,背景半透明更显眼) - 父容器:半透明黑色 box(
lv_obj_set_style_bg_opa(LV_OPA_50)),rounded corner,padding 10px
- 第 165 行删除
- 创建顺序确保层级:
lv_img_create(scr)背景图(最底层)lv_gif_create(scr)数字人 GIFlv_obj_create(scr)字幕容器(最上层)
- 字幕长文本自动换行 + 滚动(>3 行截断)
完成标志:
- ✅ AI 回复时字幕实时显示在屏幕底部,半透明背景
- ✅ 字幕不遮挡数字人头部
- ✅ 长文本超过 3 行时合理截断或滚动
产出 commit:feat(subtitle): RTC 字幕恢复 - 屏幕底部半透明,避让数字人
Phase 6: RTC 空闲超时联动
目标:60s 无对话 → 自动断 RTC + 熄屏;旧 sleep_mgr 代码用 #ifdef CONFIG_BAJI_BADGE_MODE 保留可恢复。
任务:
main/dzbj/sleep_mgr.c整体用#ifdef CONFIG_BAJI_BADGE_MODE包裹(Phase 1 已做)—— 代码保留可参考- 不删除 CMakeLists.txt 中对应 srcs(Phase 1 已包裹在
if(CONFIG_BAJI_BADGE_MODE)内) - 在
main/application.cc中新增RTC 空闲超时逻辑(不依赖 sleep_mgr):- 复用现有
listening_idle_ticks_机制 - 60s 阈值触发时:
- 调用
CloseAudioChannel()(断 RTC) - 调用
pwm_set_brightness(0)熄屏 - 暂停 LVGL 刷新
- 设置
rtc_screen_off_ = true(新变量,避免与吧唧 sleep_mgr 全局状态冲突)
- 调用
- 复用现有
- 唤醒路径:
- BOOT 按键回调 → 检查
rtc_screen_off_→ 恢复亮度 + 重连 RTC - 长按可选:触发 WiFi 重置(与配网逻辑不冲突)
- BOOT 按键回调 → 检查
- 字幕/GIF 状态在熄屏前清空(避免唤醒后残留)
RTC 空闲超时逻辑与吧唧 sleep_mgr 的隔离:
- 吧唧的
sleep_mgr_init/notify_activity/is_screen_off全部在#ifdef CONFIG_BAJI_BADGE_MODE内 - 新增的 RTC 空闲超时逻辑在 application.cc 中独立实现,使用独立的状态变量
- 这样两种模式的低功耗机制完全独立,互不干扰,将来如果再启用吧唧模式不会冲突
完成标志:
- ✅ 60s 无 RTC 交互 → 自动断开 + 熄屏
- ✅ BOOT 单击 → 屏幕亮起 + 重连 RTC(数字人 GIF 重新加载)
- ✅ 系统稳定运行 30 分钟无内存累积
- ✅ sleep_mgr.c 源代码仍在仓库中(可通过 Kconfig 重新启用)
产出 commit:feat(idle): 新增 RTC 空闲超时联动熄屏(保留 sleep_mgr 源码可恢复)
Phase 7: 集成测试 + 推送
目标:端到端验证 MILESTONE.md 第 6 节全部验收项,推送到 gitea + GitHub。
任务:
- 整机端到端测试(按 MILESTONE.md 成功标准清单逐项验证)
- 内存/CPU 监控:
heap_caps_print_heap_info(MALLOC_CAP_INTERNAL)heap_caps_print_heap_info(MALLOC_CAP_SPIRAM)- 30 分钟持续对话压测
- 用
idf.py size对比阉割前后固件大小 - 更新
docs/Rtc_AIavatar/数字人表情渲染方案_云端预渲染+BLE+OTA.md章节:阉割成果汇报 - 提交 + 推送:
git push origin Rtc_AIavatar(gitea)git push https://github.com/Leo-z8/Baji_Rtc_Toy.git Rtc_AIavatar
完成标志:
- ✅ MILESTONE.md 第 6 节成功标准全部 ✓
- ✅ gitea + GitHub 远程已同步
- ✅ 文档更新完成
产出 commit:docs(milestone): 数字人 RTC 项目完成 - 验收报告 + 性能数据
阶段依赖与并行性
- Phase 1 ⊥ Phase 2(独立,可并行做)
- Phase 3 依赖 Phase 2(需要 6MB SPIFFS)
- Phase 4/5 依赖 Phase 1(dzbj 模块清理完成)+ Phase 3(GIF 资源就位)
- Phase 4 ⊥ Phase 5(情绪映射和字幕显示独立,可并行)
- Phase 6 依赖 Phase 1(清理 sleep_mgr 调用点)
- Phase 7 必须最后
建议串行执行顺序:1 → 2 → 3 → 4 → 5 → 6 → 7(最稳)
当前状态
| Phase | 状态 |
|---|---|
| Phase 1 | ✅ 完成(commit 672506e,已推送 gitea + GitHub) |
| Phase 2 | ✅ 完成(commit ce7a3aa) |
| Phase 3 | ✅ 完成(commit 7d1c7dc) |
| Phase 4 | ✅ 完成(commit 497c1b4) |
| Phase 5 | ✅ 完成(commit f2be992) |
| Phase 6 | 🔄 进行中(B+C 双源 + 软退房 + Light Sleep 防护,最新方案见 PLAN 头部"实施变更记录") |
| Phase 7 | ⏳ 待启动 |