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

15 KiB
Raw Blame History

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=nmain 分支默认 =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.cBOOT 按键回调代码本身保留可编译公共功能KEY2 处理代码块用 #ifdef CONFIG_BAJI_BADGE_MODE 包裹

  • main/dzbj/ai_chat_ui.c:清理对吧唧界面的跳转(用 #ifdef 保护,不删代码)

1.4 头文件 stub 处理

对于条件编译后未链接的吧唧模块,其他保留模块若有引用:

  • 头文件本身仍存在(包含 prototype
  • 若调用点未用 #ifdef 保护就会链接报错
  • 解决:在调用点全部加 #ifdef(首选);或在 .h 内提供 stub 实现(次选)

1.5 任务清单

  1. 修改 main/Kconfig.projbuild 新增 CONFIG_BAJI_BADGE_MODE 开关
  2. 修改 main/CMakeLists.txt 把吧唧 srcs 包裹在 if(CONFIG_BAJI_BADGE_MODE)
  3. 修改 main/application.cc#ifdef 保护
  4. 修改 main/boards/movecall-moji-esp32s3/movecall_moji_esp32s3.cc#ifdef 保护
  5. 修改 main/dzbj/ai_chat_ui.c 跳转点加 #ifdef 保护
  6. 修改 main/dzbj/dzbj_button.c KEY2 代码块加 #ifdef 保护
  7. 修改 main/dzbj/sleep_mgr.c 整体用 #ifdef CONFIG_BAJI_BADGE_MODE 包裹Phase 6 改造为 RTC 联动版)
  8. 修改 sdkconfig.defaults(或 sdkconfig.ci)确保 Rtc_AIavatar 默认 CONFIG_BAJI_BADGE_MODE=n
  9. 产出 .planning/milestones/digital_human_rtc/BADGE_MODE_ISOLATION_MAP.md,列出所有 #ifdef 边界位置

完成标志

  • CONFIG_BAJI_BADGE_MODE=nidf.py build 编译通过
  • CONFIG_BAJI_BADGE_MODE=yidf.py build 也编译通过(G7 验收,可恢复双模式
  • 烧录 CONFIG_BAJI_BADGE_MODE=n 版本:开机直接进入 AI 对话界面(无模式选择)
  • main/dzbj/ 下所有源文件仍然存在(未删除)
  • BADGE_MODE_ISOLATION_MAP.md 已生成

风险点

  • C++ 类成员函数无法用 #ifdef 完全屏蔽(如 Application 类的吧唧成员变量),需要把成员变量也用 #ifdef 包裹
  • 头文件相互 include 可能导致循环 #ifdef,必要时改用 forward declaration

产出 commitfeat(kconfig): 引入 CONFIG_BAJI_BADGE_MODE 开关 - 吧唧模式可条件编译屏蔽


Phase 2: 分区表调整

目标:扩容 SPIFFS 到 6MB 装下 3 个 GIF + 背景图。

任务

  1. 修改 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 分区(暂未用)
  1. 验证:idf.py partition-table 总和 = 16MB 减去引导区
  2. 烧录后: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

产出 commitchore(partitions): app 双 OTA 4MB + SPIFFS 6MB为数字人 GIF 扩容)


Phase 3: GIF 资源准备

目标:准备 m03/m06/m07 三个 hiyori GIFgifsicle 处理 + 居中裁剪。

任务

  1. 源 GIF 位置:docs/Rtc_AIavatar/Resources/hiyori_free_zh/Export/m{03,06,07}/hiyori_m{03,06,07}.gif
  2. 用 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
  1. 验证每个 GIF
    • 文件大小(合计 ≤ 5.5MB 留余量)
    • gifsicle --info 检查每帧有 transparent 索引
    • 设备烧录单独测试每个 GIF临时改 bg_gif_demo_start 参数)

完成标志

  • spiffs_image/hiyori_m{03,06,07}.gif 三个文件存在
  • 每个文件 < 2MB三个合计 < 5.5MB
  • 设备烧录后三个 GIF 都能透明显示,无锯齿

产出 commitfeat(assets): 准备 hiyori 三表情 GIFm03/m06/m07+ Python 处理脚本


Phase 4: 情绪 → GIF 映射

目标22 种情绪标签 → 3 个 GIF 的映射表RTC 字幕情绪自动切换 GIF。

任务

  1. 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"},
};
  1. 实现 ai_chat_set_emotion(const char *emotion)
    • 查表 → 调用 bg_gif_demo_switch_gif(path)
    • 静态变量 last_gif_path 去重避免重复加载
  2. bg_gif_demo.cswitch_gif() 接口:
    • 释放旧 GIF PSRAM
    • 加载新 GIF
    • lv_gif_set_src(g_gif_obj, &g_gif_dsc)
    • 重新设置定时器周期 20ms避免恢复默认 10ms
  3. application.cc / volc_rtc_protocol.cc 字幕回调中调用 ai_chat_set_emotion()
  4. 字幕到达时立即触发(不等 is_finallast_subtitle_emotion 去重

完成标志

  • AI 回复"happy你好"时 GIF 切到 m06
  • AI 回复"sad抱歉"时 GIF 切到 m03
  • 切换间无内存泄漏(连续切 50 次 PSRAM 不持续减少)

产出 commitfeat(emotion): 情绪标签 → hiyori GIF 映射 + bg_gif_demo 切换接口


Phase 5: RTC 字幕恢复

目标:屏幕底部半透明字幕显示,不遮挡数字人。

任务

  1. 修改 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)
      • 宽度 300pxwrap 模式
      • 字体 font_puhui_20_4,颜色 0xFFFFFF白色背景半透明更显眼
      • 父容器:半透明黑色 boxlv_obj_set_style_bg_opa(LV_OPA_50)rounded cornerpadding 10px
  2. 创建顺序确保层级:
    • lv_img_create(scr) 背景图(最底层)
    • lv_gif_create(scr) 数字人 GIF
    • lv_obj_create(scr) 字幕容器(最上层)
  3. 字幕长文本自动换行 + 滚动(>3 行截断)

完成标志

  • AI 回复时字幕实时显示在屏幕底部,半透明背景
  • 字幕不遮挡数字人头部
  • 长文本超过 3 行时合理截断或滚动

产出 commitfeat(subtitle): RTC 字幕恢复 - 屏幕底部半透明,避让数字人


Phase 6: RTC 空闲超时联动

目标60s 无对话 → 自动断 RTC + 熄屏;旧 sleep_mgr 代码用 #ifdef CONFIG_BAJI_BADGE_MODE 保留可恢复。

任务

  1. main/dzbj/sleep_mgr.c 整体用 #ifdef CONFIG_BAJI_BADGE_MODE 包裹Phase 1 已做)—— 代码保留可参考
  2. 不删除 CMakeLists.txt 中对应 srcsPhase 1 已包裹在 if(CONFIG_BAJI_BADGE_MODE) 内)
  3. main/application.cc新增RTC 空闲超时逻辑(不依赖 sleep_mgr
    • 复用现有 listening_idle_ticks_ 机制
    • 60s 阈值触发时:
      • 调用 CloseAudioChannel()(断 RTC
      • 调用 pwm_set_brightness(0) 熄屏
      • 暂停 LVGL 刷新
      • 设置 rtc_screen_off_ = true新变量,避免与吧唧 sleep_mgr 全局状态冲突)
  4. 唤醒路径:
    • BOOT 按键回调 → 检查 rtc_screen_off_ → 恢复亮度 + 重连 RTC
    • 长按可选:触发 WiFi 重置(与配网逻辑不冲突)
  5. 字幕/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 重新启用)

产出 commitfeat(idle): 新增 RTC 空闲超时联动熄屏(保留 sleep_mgr 源码可恢复)


Phase 7: 集成测试 + 推送

目标:端到端验证 MILESTONE.md 第 6 节全部验收项,推送到 gitea + GitHub。

任务

  1. 整机端到端测试(按 MILESTONE.md 成功标准清单逐项验证)
  2. 内存/CPU 监控:
    • heap_caps_print_heap_info(MALLOC_CAP_INTERNAL)
    • heap_caps_print_heap_info(MALLOC_CAP_SPIRAM)
    • 30 分钟持续对话压测
  3. idf.py size 对比阉割前后固件大小
  4. 更新 docs/Rtc_AIavatar/数字人表情渲染方案_云端预渲染+BLE+OTA.md 章节:阉割成果汇报
  5. 提交 + 推送:
    • git push origin Rtc_AIavatargitea
    • git push https://github.com/Leo-z8/Baji_Rtc_Toy.git Rtc_AIavatar

完成标志

  • MILESTONE.md 第 6 节成功标准全部 ✓
  • gitea + GitHub 远程已同步
  • 文档更新完成

产出 commitdocs(milestone): 数字人 RTC 项目完成 - 验收报告 + 性能数据


阶段依赖与并行性

  • Phase 1 ⊥ Phase 2独立可并行做
  • Phase 3 依赖 Phase 2需要 6MB SPIFFS
  • Phase 4/5 依赖 Phase 1dzbj 模块清理完成)+ Phase 3GIF 资源就位)
  • 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 待启动