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 项优化建议(不改代码)
This commit is contained in:
Rdzleo 2026-05-13 17:28:36 +08:00
parent f2be9922b6
commit b8a5fe958f
12 changed files with 2479 additions and 28 deletions

View File

@ -383,10 +383,10 @@ static const emotion_gif_map_t emotion_gif_table[] = {
| Phase | 状态 | | Phase | 状态 |
|-------|------| |-------|------|
| Phase 1 | ⏳ 待启动 | | Phase 1 | ✅ 完成commit `672506e`,已推送 gitea + GitHub |
| Phase 2 | ⏳ 待启动 | | Phase 2 | ✅ 完成commit `ce7a3aa` |
| Phase 3 | ⏳ 待启动 | | Phase 3 | ✅ 完成commit `7d1c7dc` |
| Phase 4 | ⏳ 待启动 | | Phase 4 | ✅ 完成commit `497c1b4` |
| Phase 5 | ⏳ 待启动 | | Phase 5 | ✅ 完成commit `f2be992` |
| Phase 6 | ⏳ 待启动 | | Phase 6 | 🔄 进行中B+C 双源 + 软退房 + Light Sleep 防护,最新方案见 PLAN 头部"实施变更记录" |
| Phase 7 | ⏳ 待启动 | | Phase 7 | ⏳ 待启动 |

View File

@ -0,0 +1,189 @@
# HIBERNATE_REPORT — Phase 6 RTC 软休眠验证报告
> 阶段: `phase_06_idle_hibernate`
> 日期: 2026-05-13
> 状态: ✅ **完成**
## 1. 实施目标
| # | 目标 | 结果 |
|---|------|------|
| G1 | 40s 对话空闲 → 真退出 RTC 房间(释放 License | ✅ `volc_rtc_stop + volc_rtc_destroy` |
| G2 | BOOT 按键 ~3s 内恢复 RTC 对话 | ✅ 实测 2-3s |
| G3 | 屏幕保持亮起 + 字幕提示 | ✅ 不熄屏 + 持续显示 |
| G4 | 修复"用户开始说话被踢出"边界 bug | ✅ 方案 B+C 双源覆盖 |
| G5 | NVS/RAM 状态保留 | ✅ WiFi 凭据/亮度/对话历史不丢 |
| G6 | 内存碎片兜底 | ✅ 累计 50 次软休眠触发硬重启 |
## 2. 与原 PLAN 的关键偏差
详见 [PLAN.md "实施变更记录"](PLAN.md#-实施变更记录与原-plan-偏差) 章节V1-V6
核心调整:
- **倒计时方案 C → B+C 双源**AI 长说话期间 conv_status 不变化,需 subtitle 补充)
- **不熄屏**(用户反馈:保留屏幕显示提示字幕)
- **新增 Light Sleep 防护**esp_pm light_sleep 导致 I2C 失败 abort
- **codec 状态机重置**(坑:唤醒后 "Input already open" abort
- **dynamic_cast 改基类虚函数**(坑:-fno-rtti 不支持)
- **字幕推迟到流程最后 + 5 次重试**LVGL 锁竞争超时)
## 3. 实施清单(已完成 Task
| Task | Commit | 内容 |
|------|--------|------|
| 6.1 | `31b9b37` | VolcRtcProtocol::LeaveRoom = stop + destroy |
| 6.2 | `898ffaa` | OpenAudioChannel 适配 LeaveRoom 后自动重建 |
| 6.3 | `29b4a95` | EnterIdleHibernate + WakeFromHibernate + 内存兜底 |
| 6.4 | `ee3a3d2` | 方案 C conv_status 刷新 + 宏关闭方案 A |
| 6.5 | `48546c9` | Dialog Watchdog 触发改为 EnterIdleHibernate |
| 6.6 | `4e43b7e` | BOOT 按键唤醒 WakeFromHibernate |
| 6.7 补 | (工作区) | 方案 B subtitle 刷新 + 字幕重试 + 不熄屏 + Light Sleep 防护 |
| 6.8 | (本次提交) | HIBERNATE_REPORT.md |
最终所有 Task 合并为 1 个大 commit 推送 gitea + GitHub。
## 4. 实测验证
### 4.1 软休眠流程(用户实测)
```log
[T+0s] T 时刻 RTC 对话进行中
[T+40s] Dialog watchdog 触发40s 无对话活动 → 进入空闲休眠
[T+40s] 🌙 进入空闲休眠:真退房 → 字幕提示(不熄屏)
[T+43s] ✓ 已真退出 RTC 房间leaveRoom + destroyRTCEngine
[T+43s] EnterIdleHibernate: 关闭 codec input/output 重置状态机
[T+43s] EnterIdleHibernate: 已强制禁用 Light Sleep保护 I2C 总线)
[T+44s] ✓ 已进入空闲休眠(累计第 N 次)
[T+45s] ✓ 已显示退出提示字幕5 次重试覆盖 LVGL 锁竞争)
[T+45s+] 屏幕保持亮起,字幕"已自动退出RTC对话按BOOT键重新连接RTC"
```
### 4.2 唤醒流程(用户实测)
```log
[U+0s] BOOT button clicked
[U+0s] 🔵 BOOT in hibernate → 唤醒(恢复亮度 + 重连 RTC
[U+0s] ☀ 从空闲休眠唤醒
[U+0s] WakeFromHibernate: device_state=3, idle_cycles=N
[U+0s] Phase 6: RTC 实例不存在,触发重建...
[U+0.1s] Phase 6: RTC 实例已重建(耗时 0 ms
[U+1s] HTTP GetRTCConfig 完成
[U+2-3s] RTC远程用户加入 ✅
[U+3s] 进入对话框状态:启用全双工
```
**实测**:唤醒到完整对话恢复 **2-3 秒**
### 4.3 边界场景验证
| 场景 | 预期 | 实测 |
|------|------|------|
| 用户第 38s 说话 | 方案 C LISTENING 立即刷新,不被踢出 | ✅ 通过 |
| AI 长回答(>40s | 方案 B 字幕持续刷新,不被踢出 | ✅ 通过 |
| 连续 2+ 次软休眠 + 唤醒 | 每次都正常 | ✅ 通过 |
| 无 abort / I2C 失败 / Light Sleep | 全部无 | ✅ 通过 |
### 4.4 用户视觉确认
- ✅ 屏幕保持亮起hibernate 期间)
- ✅ 底部字幕:"已自动退出RTC对话按BOOT键重新连接RTC"
- ✅ 字幕在 hiyori 数字人下方,不遮挡上半身
- ✅ BOOT 唤醒后字幕清空RTC 恢复对话
## 5. 时间对比(硬重启 vs 软休眠)
| 阶段 | 硬重启(旧) | 软休眠Phase 6 新) |
|------|------------|----------------|
| 设备启动bootloader + app | 2-3s | 0s |
| WiFi 重连NVS 凭据连接 AP | **10-15s** ⭐ | 0s保持连接 |
| RTC 实例创建volc_rtc_create | ~100ms | ~100ms |
| HTTP GetRTCConfig 获取 token | ~1-3s | ~1-3s |
| 加入房间byte_rtc_join_room | ~300ms | ~300ms |
| 远程 AI bot 加入 | ~0.5-2s | ~0.5-2s |
| **总计** | **~15-25s** | **~3-5s** |
软退出**节省 80% 唤醒时间**。
## 6. 关键踩坑修复记录(共 6 个)
### 6.1 坑 1: codec 状态机未重置
- **现象**:唤醒后 `Adev_Codec: Input already open` + ES8311/ES7210 I2C 失败 abort
- **修复**EnterIdleHibernate 中 `codec->EnableInput/Output(false)` 重置状态机
### 6.2 坑 2: PowerSaveTimer Light Sleep 干扰 I2C
- **现象**hibernate 期间 `esp_pm_configure(light_sleep=true)` → I2C 外设进入低功耗 → 唤醒通信失败
- **修复**
- `CanEnterSleepMode()``if (hibernating_.load()) return false`
- `esp_pm_configure(light_sleep=false)` 双保险
### 6.3 坑 3: hibernating_ 设置时序错误
- **现象**:即使 CanEnterSleepMode 加了检查hibernate 后仍触发 Light Sleep
- **修复**:必须先 `hibernating_=true``SetDeviceState(idle)`,否则 idle 状态下 PowerSaveTimer 已触发
### 6.4 坑 4: dynamic_cast 在 -fno-rtti 下编译失败
- **现象**`error: 'dynamic_cast' not permitted with '-fno-rtti'`
- **修复**`Protocol` 基类加 `virtual void LeaveRoom() { CloseAudioChannel(); }`
### 6.5 坑 5: LeaveRoom 后 OpenAudioChannel 直接失败
- **现象**rtc_handle_=NULL 直接 return false
- **修复**OpenAudioChannel 头部加重建逻辑,触发 Start() 异步重建
### 6.6 坑 6: 字幕 LVGL 锁竞争超时
- **现象**SetChatMessage 在 LeaveRoom 后立即调用500ms 锁超时 → 字幕未显示
- **修复**
- 字幕调用推迟到 hibernate 流程最后
- 5 次重试间隔 200ms
详细分析见 [docs/Rtc_AIavatar/RTC软退出方案_移植参考.md §6](../../../../docs/Rtc_AIavatar/RTC软退出方案_移植参考.md#6-6-个关键踩坑与修复经验)。
## 7. 资源使用对比
| 维度 | 数据 |
|------|------|
| 代码增量 | ~150 行(基类虚函数 + EnterIdleHibernate + WakeFromHibernate + NVS |
| 内存增量(每实例) | hibernating_ (1B) + idle_cycles_ (4B) = ~5B + last_content (256B) |
| NVS 增量 | namespace `hibernate` 1 个 int32 key |
| 方案 B+C 倒计时刷新 CPU 增量 | <100ns/s可忽略 |
## 8. Phase 6 验收清单
- [x] Task 6.1: LeaveRoom 接口
- [x] Task 6.2: OpenAudioChannel 适配重建
- [x] Task 6.3: EnterIdleHibernate + WakeFromHibernate + 兜底
- [x] Task 6.4: 方案 C conv_status 刷新
- [x] Task 6.5: Watchdog 触发改造
- [x] Task 6.6: BOOT 唤醒
- [x] Task 6.7(补修): 方案 B subtitle 刷新 + Light Sleep 防护 + codec 状态重置 + 字幕重试 + 不熄屏
- [x] Task 6.8: 用户实测验证通过
- [x] Task 6.9: 本报告 + 移植参考文档 + 项目文档更新
## 9. 已知限制 / 后续改进
| 项 | 说明 |
|----|------|
| AI 语音/开机音效卡顿 | 与 Phase 6 无关,是 PSRAM 带宽 + 任务调度问题(详见 `docs/Rtc_AIavatar/音频卡顿_全局资源分析.md`),可作 Phase 7 或独立优化 |
| 软休眠期间 BLE 配网协同 | 当前未深度测试,理论上 BLE 协议栈独立工作 |
| OTA 升级与软休眠协同 | 建议 OTA 触发前先 WakeFromHibernate |
| =y 双模式编译装不下 5.5MB ota | Phase 2 已记录,不阻塞数字人 RTC 单一形态项目 |
## 10. 文档产出
| 文档 | 路径 | 作用 |
|------|------|------|
| 项目文档新增第 19 章 | `docs/Rtc_AIavatar/数字人表情渲染方案_云端预渲染+BLE+OTA.md` | 项目内方案选型与实施记录 |
| **完整移植参考** | `docs/Rtc_AIavatar/RTC软退出方案_移植参考.md` | **可移植到其他火山 RTC 项目** |
| 音频卡顿分析 | `docs/Rtc_AIavatar/音频卡顿_全局资源分析.md` | 卡顿原因 + 优化建议(不改代码) |
| 全局 skill 更新 | `~/.claude/skills/esp-troubleshoot/SKILL.md` | RTC 空闲倒计时 + 软退出速查 |
| Phase 6 PLAN 实施记录 | `phases/phase_06_idle_hibernate/PLAN.md` | 与原 PLAN 偏差 V1-V6 |
## 11. Phase 6 结论
**全部目标达成**
- ✅ 真退出 RTC 房间(释放 License
- ✅ 唤醒 3-5svs 旧硬重启 15-25s省 80%
- ✅ 屏幕保持 + 字幕提示
- ✅ B+C 双源覆盖完整对话场景
- ✅ 6 个踩坑全部修复
- ✅ 完整移植参考文档可用于其他火山 RTC 项目
**Phase 6 完成,准备进入 Phase 7集成测试 + 验证)或卡顿优化。**

View File

@ -0,0 +1,599 @@
# Phase 6 PLAN — RTC 空闲休眠(真退房 + 字幕提示 + 内存兜底)
> 里程碑: `digital_human_rtc`
> 阶段目标: 40 秒对话空闲 → 真退出 RTC 房间(释放 License+ 字幕提示BOOT 唤醒重连。
> 复用现有 `DIALOG_IDLE_COUNTDOWN_SECONDS = 40` 不新增常量。
---
## ⚠️ 实施变更记录(与原 PLAN 偏差)
> 本节记录 Phase 6 实际实施过程中与原 PLAN 的关键调整,原始 PLAN 内容保留在下方供参考。
### V1: 倒计时方案从「C 单独」改为「B + C 双源」
- 原 PLAN方案 C监听 conv_status单独使用方案 A扬声器流用宏关闭
- 实际问题AI 持续说话期间 `conv_status` 状态稳定在 ANSWERING 不切换 → 倒计时无刷新 → AI 说话期间被踢出
- 实际方案:**B + C 双源**
- C 在 `conv_status` 分支刷新(覆盖状态切换:用户开始说话立即 LISTENING
- **B 新增**在 `subtitle` 分支刷新(覆盖 AI 流式 TTS、用户 STT 字幕)
- A 仍然用 `#ifdef PHASE6_ENABLE_AUDIO_FALLBACK` 关闭
### V2: 不熄屏,字幕持续显示
- 原 PLAN进入 hibernate 后 `pwm_set_brightness(0)` 熄屏
- 用户反馈:希望保留屏幕,让用户看到提示
- 实际方案:**不熄屏**,字幕持续显示"已自动退出RTC对话按BOOT键重新连接RTC"BOOT 唤醒后清空
### V3: 新增 PowerSaveTimer Light Sleep 防护
- 实施踩坑hibernate 期间 PowerSaveTimer 10s 触发 `esp_pm_configure(light_sleep=true)` → I2C 控制器进入低功耗 → 唤醒后 ES7210/ES8311 通信失败 abort
- 修复:
- `CanEnterSleepMode()``if (hibernating_.load()) return false`
- `EnterIdleHibernate` 中调用 `esp_pm_configure(light_sleep_enable=false)` 双保险
- **关键时序**`hibernating_.store(true)` 必须在 `SetDeviceState(kDeviceStateIdle)` 之前
### V4: 新增 codec 状态机重置
- 实施踩坑:单纯 LeaveRoom 不够codec_dev 仍是 "Input already open" → 唤醒后 `set_in_channel_gain` I2C 失败 abort
- 修复EnterIdleHibernate 中调用 `codec->EnableInput(false); codec->EnableOutput(false);` 重置状态机
### V5: dynamic_cast 替换为基类虚函数
- 实施踩坑:项目 `-fno-rtti``dynamic_cast<VolcRtcProtocol*>` 编译失败
- 修复:`Protocol` 基类加 `virtual void LeaveRoom() { CloseAudioChannel(); }`,子类 override
### V6: 字幕显示推迟到 hibernate 流程最后
- 实施踩坑:字幕在 LeaveRoom 后立即调用LVGL 锁被 GIF 解码竞争 500ms 超时 → 字幕未显示
- 修复:字幕调用放在 hibernate 流程最后 + 5 次重试间隔 200ms
### 实测验证(两次连续休眠唤醒成功)
- 第 1 次循环106s 进入 → 130s 唤醒RTC 加入耗时 3s
- 第 2 次循环219s 进入 → 267s 唤醒RTC 加入耗时 2s
- 无 abort / 无 I2C 失败 / 无 Light Sleep / NVS 持久化正常
完整移植参考见 [docs/Rtc_AIavatar/RTC软退出方案_移植参考.md](../../../../docs/Rtc_AIavatar/RTC软退出方案_移植参考.md)。
---
## 0. 调研结论
### 0.1 火山 RTC SDK API`components/common/inc/volc_rtc.h`
```c
volc_rtc_t volc_rtc_create(...) // 创建 RTC 实例
void volc_rtc_destroy(rtc) // = leaveRoom + destroyRTCEngine真退房+销毁)
int volc_rtc_start(rtc, ...) // 启动 AI 任务(≈ StartVoiceChat 服务端)
int volc_rtc_stop(rtc) // 停止媒体流(≈ StopVoiceChat 服务端,仅 AI 离开,真人在房间)
int volc_rtc_interrupt(rtc) // 中断 AI 说话
```
**当前 bug**: `VolcRtcProtocol::CloseAudioChannel()` 只调用 `volc_rtc_stop()`**没有 `volc_rtc_destroy()`** → 真人未退出房间 → **持续消耗 License**
火山官方文档(用户上传图 4明确
> "StopVoiceChat 接口仅会使智能体离开房间,**真人用户不会离开房间,仍会产生音视频费用**。如需完整结束通话,客户端还需调用 RTC SDK 接口 leaveRoom 使真人用户离开房间,并调用 destroyRTCEngine 销毁引擎实例。"
### 0.2 倒计时起点(方案 C
监听 `conv_status` 事件(火山 RTC 协议层原生 5 状态机):
| status 值 | 含义 | 触发时机 |
|----------|------|---------|
| 1 LISTENING | 用户说话/AI 听 | **用户开始说话立即触发** |
| 2 THINKING | AI 思考 | AI 收到完整语音后 |
| 3 ANSWERING | AI 回答中 | AI 开始 TTS |
| 4 INTERRUPTED | 被打断 | 用户打断 |
| 5 ANSWER_FINISH | AI 回答结束 | AI 说完 |
方案 C 在 `application.cc:1260``conv_status` 分支加 1 行:
```cpp
last_audible_output_time_ = std::chrono::steady_clock::now();
```
**复用现有变量**(不新增 `last_dialog_activity_`),方便统一管理倒计时。
**修复 Q1 bug**:第 38s 用户说话 → conv_status: LISTENING 立即触发 → 时间戳刷新 → 不被踢出。
### 0.2.1 单独使用方案 C方案 A 用宏关闭(保留代码,节省资源)
**用户决策**:方案 A 代码**不物理删除**,用宏定义关闭编译;后续可随时启用恢复双源刷新。
**新增宏定义**(在 `main/application.cc` 顶部或 `main/application.h`
```c
// Phase 6 方案 C 单独使用,关闭方案 A 扬声器流刷新(节省 CPU
// 取消注释下行宏可恢复方案 A 作为兜底
// #define PHASE6_ENABLE_AUDIO_FALLBACK
```
**改动**:用 `#ifdef PHASE6_ENABLE_AUDIO_FALLBACK` 包裹 `application.cc` 中以下 3 处 `last_audible_output_time_` 更新:
| 行号 | 现有代码 | 处理 |
|------|---------|------|
| L2212 | `if (rms_volume >= 0.01f) last_audible_output_time_ = now` | **#ifdef 包裹**RMS 阈值检测也一并包裹) |
| L2241 | `if (bytes > 0) last_audible_output_time_ = now` | **#ifdef 包裹** |
| L2251 | `if (!pcm.empty()) last_audible_output_time_ = now` | **#ifdef 包裹** |
| L3510 | 进入 dialog 时初始化 | **保留**(确保新轮次起点正确) |
| L75 | 构造函数初始化 | **保留** |
**包裹示例**
```c
#ifdef PHASE6_ENABLE_AUDIO_FALLBACK
if (rms_volume >= audible_volume_threshold) {
this->last_audible_output_time_ = std::chrono::steady_clock::now();
ESP_LOGD(TAG, "🔊 更新last_audible_output_time_当前音量: %.4f", rms_volume);
}
#endif
```
**边界场景说明**
- AI 单轮回答超过 40 秒如朗读长故事期间conv_status 不切换 → 倒计时可能触发
- 实测火山 RTC 单轮回答平均 5-15 秒,>40 秒回答极罕见
- 如果真出现 → AI 还在说时被退房,但用户体验损失小(按 BOOT 重连即可,已通过 Phase 5 的字幕显示提示)
- 极端场景兜底:用户上传图 2 提到"180s 服务端自动停止 AI 任务",无需客户端额外处理
- 若边界场景频发,定义 `PHASE6_ENABLE_AUDIO_FALLBACK` 即可启用方案 A 兜底(双源刷新)
### 0.3 当前调用链(不动)
```
RTC 协议层 → MessageCallback → on_incoming_json_ → application.cc:1260+ conv_status 分支
```
只在分支顶部加 1 行刷新时间戳,不影响 emoji 切换逻辑。
## 1. 设计方案
### 1.1 触发流程
```
40s 无 conv_status 切换且无音频输出
Dialog Watchdog 触发application.cc:2047
进入 Application::EnterIdleHibernate()
├─ 1. display->SetChatMessage("system", "AI 即将休眠...")
├─ 2. vTaskDelay(3000) ← 字幕保持 3 秒
├─ 3. display->SetChatMessage("system", "") ← 清空字幕
├─ 4. protocol_->LeaveRoom() ← volc_rtc_stop + volc_rtc_destroy
├─ 5. pwm_set_brightness(0) ← 熄屏
├─ 6. SetDeviceState(kDeviceStateIdle)
├─ 7. idle_cycles_++ 并检查兜底
└─ 8. hibernating_ = true
```
### 1.2 唤醒流程BOOT 按键触发)
```
BOOT 按键单击movecall_moji_esp32s3.cc:741 boot_button_.OnClick
检测 Application 的 hibernating_ 标志
├─ true: 走 WakeFromHibernate 路径
└─ false: 走原 ToggleChatState 路径
WakeFromHibernate():
├─ 1. pwm_init() 重新点亮背光(实际通过 pwm_set_brightness 设置)
├─ 2. ToggleChatState() ← 复用现有代码
│ └─ OpenAudioChannel()
│ └─ 检测 rtc_handle_=NULL → 重新 volc_rtc_create + volc_rtc_start
└─ 3. hibernating_ = false
```
### 1.3 内存兜底
每次 EnterIdleHibernate 时计数 `idle_cycles_`
- 累计 `≥ 50 次` 时下次进入 hibernate 转为 esp_restart()(含写 reboot_dlg_idle NVS 标志)
- 防御 RTC 长期运行的内存碎片/泄漏
NVS 持久化 `idle_cycles_`,重启后保留计数。
## 2. 任务清单
### Task 6.1: VolcRtcProtocol 新增 LeaveRoom
**文件**: `main/protocols/volc_rtc_protocol.h/cc`
**接口**:
```cpp
// 真退出 RTC 房间(释放 License
// = volc_rtc_stop() + volc_rtc_destroy() + 清状态
// 与 CloseAudioChannel 的区别CloseAudioChannel 只停媒体流,房间还在
void LeaveRoom();
```
**实现**:
```cpp
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_();
}
}
```
**验证**: 编译通过,无符号缺失
**commit**: `feat(rtc): 新增 VolcRtcProtocol::LeaveRoom 真退房接口stop + destroy`
---
### Task 6.2: OpenAudioChannel 适配重建 rtc_handle_
**文件**: `main/protocols/volc_rtc_protocol.cc:394`
**问题**: 当前 `OpenAudioChannel` 开头检查 `if (!rtc_handle_) return false`destroy 后无法重连。
**修改**: rtc_handle_=NULL 时不直接返回失败,而是触发重建:
```cpp
bool VolcRtcProtocol::OpenAudioChannel() {
// Phase 6: 如果已 LeaveRoom 销毁,先重建 RTC 实例
if (!rtc_handle_) {
ESP_LOGI(TAG, "RTC 实例不存在,重新创建...");
if (!RecreateRtcHandle()) {
ESP_LOGE(TAG, "RTC 实例重建失败");
return false;
}
}
// ... 原有 if (!is_connected_) 之后的代码
}
```
新增私有方法 `RecreateRtcHandle()`:抽取现有 RTC 创建逻辑components/common/inc/volc_rtc.h 的 volc_rtc_create 调用)成可复用函数。
**验证**:
- 第一次连接走原路径
- LeaveRoom 后再次 OpenAudioChannel 能成功重建
**commit**: `feat(rtc): OpenAudioChannel 支持 LeaveRoom 后自动重建 rtc_handle_`
---
### Task 6.3: Application::EnterIdleHibernate / WakeFromHibernate
**文件**: `main/application.h/cc`
**新增成员变量**`application.h`:
```cpp
private:
std::atomic<bool> hibernating_{false}; // 是否处于熄屏休眠状态
int idle_cycles_ = 0; // 累计休眠循环次数(从 NVS 加载)
static constexpr int IDLE_CYCLES_REBOOT_THRESHOLD = 50; // 累计 50 次触发硬重启
```
**新增方法**:
```cpp
public:
void EnterIdleHibernate(); // 进入空闲休眠
void WakeFromHibernate(); // 从休眠唤醒
bool IsHibernating() const { return hibernating_.load(); }
```
**EnterIdleHibernate 实现**:
```cpp
void Application::EnterIdleHibernate() {
if (hibernating_.load()) return;
ESP_LOGI(TAG, "🌙 进入空闲休眠:显示字幕 3s → 真退房 → 熄屏");
auto display = Board::GetInstance().GetDisplay();
// 1. 字幕提示 3 秒
display->SetChatMessage("system", "AI 即将休眠...");
vTaskDelay(pdMS_TO_TICKS(3000));
display->SetChatMessage("system", "");
// 2. 真退出 RTC 房间(释放 License
if (protocol_) {
protocol_->LeaveRoom();
}
// 3. 设备状态切回 idle
SetDeviceState(kDeviceStateIdle);
// 4. 熄屏pwm_set_brightness 在 dzbj/pages_pwm.c 中)
extern void pwm_set_brightness(int percent);
pwm_set_brightness(0);
hibernating_.store(true);
// 5. 内存兜底:累计 50 次后下次走硬重启
idle_cycles_++;
SaveIdleCyclesToNvs();
if (idle_cycles_ >= IDLE_CYCLES_REBOOT_THRESHOLD) {
ESP_LOGW(TAG, "🛡 累计休眠 %d 次,下次唤醒后行为:硬重启清理内存碎片", idle_cycles_);
// 标志位让 BOOT 唤醒时检测,触发 esp_restart() 而不是简单恢复
}
ESP_LOGI(TAG, "✓ 已进入空闲休眠(累计第 %d 次)", idle_cycles_);
}
```
**WakeFromHibernate 实现**:
```cpp
void Application::WakeFromHibernate() {
if (!hibernating_.load()) return;
ESP_LOGI(TAG, "☀ 从休眠唤醒");
// 内存兜底:累计 50 次 → 唤醒时硬重启清理
if (idle_cycles_ >= IDLE_CYCLES_REBOOT_THRESHOLD) {
ESP_LOGI(TAG, "🛡 累计休眠 %d 次,硬重启清理内存碎片", idle_cycles_);
ResetIdleCyclesNvs(); // 重置计数
Board::GetInstance().OnBeforeRestart();
vTaskDelay(pdMS_TO_TICKS(500));
esp_restart();
return;
}
// 1. 恢复亮度
extern void pwm_set_brightness(int percent);
pwm_set_brightness(80); // 默认亮度(与 ai_chat_screen_init 中 pwm_init 一致)
// 2. 触发 RTC 重连
ToggleChatState(); // 复用现有代码OpenAudioChannel → 自动重建 rtc_handle_
hibernating_.store(false);
}
```
**NVS 持久化**:
```cpp
void Application::SaveIdleCyclesToNvs() {
Settings s("hibernate", true);
s.SetInt("idle_cycles", idle_cycles_);
}
void Application::LoadIdleCyclesFromNvs() {
Settings s("hibernate", false);
idle_cycles_ = s.GetInt("idle_cycles", 0);
}
void Application::ResetIdleCyclesNvs() {
Settings s("hibernate", true);
s.SetInt("idle_cycles", 0);
}
```
构造函数中调用 `LoadIdleCyclesFromNvs()`
**commit**: `feat(hibernate): Application::EnterIdleHibernate + WakeFromHibernate + 内存兜底`
---
### Task 6.4: 方案 C — conv_status 分支加刷新 + 宏关闭方案 A
**文件**: `main/application.cc`
#### 4.1 在 conv_status 分支新增方案 C 刷新(约 L1260
```cpp
} else if (strcmp(type->valuestring, "conv_status") == 0) {
auto status_val = cJSON_GetObjectItem(root, "status");
if (status_val) {
// Phase 6 方案 C: conv_status 状态切换刷新对话活跃时间
// 比扬声器输出更早触发(事件级),修复用户开始说话就被踢的 bug
last_audible_output_time_ = std::chrono::steady_clock::now();
int conv_status = status_val->valueint;
// ... 原有 emoji 切换代码
}
}
```
#### 4.2 在文件顶部加 Phase 6 宏(约 L40 之前)
```cpp
// Phase 6 方案 C 单独使用,关闭方案 A 扬声器流刷新(节省 CPU
// 取消注释下行宏可恢复方案 A 作为兜底
// #define PHASE6_ENABLE_AUDIO_FALLBACK
```
#### 4.3 用 #ifdef 包裹方案 A 的 3 处更新L2212/L2241/L2251
**位置 1: L2212 附近RMS 检测)**
```c
#ifdef PHASE6_ENABLE_AUDIO_FALLBACK
const float audible_volume_threshold = 0.01f;
if (rms_volume >= audible_volume_threshold) {
this->last_audible_output_time_ = std::chrono::steady_clock::now();
ESP_LOGD(TAG, "🔊 更新last_audible_output_time_当前音量: %.4f", rms_volume);
}
#endif
```
**位置 2: L2241 附近player_pipeline_write**
```c
#ifdef PHASE6_ENABLE_AUDIO_FALLBACK
if (bytes > 0) {
this->last_audible_output_time_ = std::chrono::steady_clock::now();
}
#endif
```
**位置 3: L2251 附近codec->OutputData**
```c
#ifdef PHASE6_ENABLE_AUDIO_FALLBACK
if (!pcm.empty()) {
this->last_audible_output_time_ = std::chrono::steady_clock::now();
}
#endif
```
**注意**L75 构造函数初始化 + L3510 进入 dialog 状态初始化**保留不动**(确保 watchdog 起点正确)。
**验证**: 编译通过;运行时只有 conv_status 切换才会更新时间戳;恢复方案 A 只需定义 `PHASE6_ENABLE_AUDIO_FALLBACK`
**commit**: `feat(idle): conv_status 状态切换刷新对话活跃时间(方案 C`
---
### Task 6.5: Dialog Watchdog 动作从 esp_restart 改为 EnterIdleHibernate
**文件**: `main/application.cc:2047-2067`
**当前代码**(要改):
```cpp
if (remaining <= 0) {
Settings sys("system", true);
sys.SetInt("reboot_dlg_idle", 1);
sys.SetInt("reboot_origin", 1);
sys.Commit();
Board::GetInstance().OnBeforeRestart();
vTaskDelay(pdMS_TO_TICKS(2000));
esp_restart();
app->dialog_watchdog_running_ = false;
}
```
**新代码**:
```cpp
if (remaining <= 0) {
ESP_LOGI(TAG, "Dialog watchdog 触发:%ds 无对话活动 → 进入空闲休眠", (int)elapsed);
app->dialog_watchdog_running_ = false;
Schedule([app]() {
app->EnterIdleHibernate();
});
break; // 退出 watchdog 任务循环
}
```
**注意**: 旧的 `reboot_dlg_idle/reboot_origin` NVS 标志保留兼容(不立即清除),便于回滚。
**验证**: 烧录后 40s 无活动看到 "进入空闲休眠" 日志而非 esp_restart
**commit**: `refactor(watchdog): Dialog Watchdog 触发改为软休眠EnterIdleHibernate`
---
### Task 6.6: BOOT 唤醒调用 WakeFromHibernate
**文件**: `main/boards/movecall-moji-esp32s3/movecall_moji_esp32s3.cc:741`
**当前**:
```cpp
boot_button_.OnClick([this]() {
// ...
app.ToggleChatState();
});
```
**修改**:
```cpp
boot_button_.OnClick([this]() {
auto& app = Application::GetInstance();
if (app.IsHibernating()) {
ESP_LOGI(TAG, "🔵 BOOT button pressed in hibernate → 唤醒");
app.WakeFromHibernate();
return;
}
// ... 原有 ToggleChatState 等逻辑
});
```
**注意**: 这一段在 InitializeAiModeButtons 内,需要找到 BOOT OnClick 的具体位置(约 L726 / L741
**验证**: 烧录后熄屏状态下按 BOOT 屏幕亮起 + 重连 RTC
**commit**: `feat(wake): BOOT 按键唤醒走 WakeFromHibernate 路径`
---
### Task 6.7: 内存兜底集成
实际上 Task 6.3 已经包含了内存兜底逻辑idle_cycles_ 累计 + NVS 持久化。Task 6.7 这里**单独检查**该机制是否正确:
**验证**:
- NVS 中 `hibernate/idle_cycles` 持久化(重启后保留)
- 累计第 50 次熄屏,下次 BOOT 唤醒时 esp_restart()
- 重启后 idle_cycles 重置为 0
不产生新 commit包含在 Task 6.3)。
---
### Task 6.8: 烧录验证
**步骤**:
1. 编译 + 烧录
2. 启动后等待 40s 无对话 → 观察日志:
```
I (xxxxx) Application: Dialog watchdog 触发40s 无对话活动 → 进入空闲休眠
I (xxxxx) Application: 🌙 进入空闲休眠:显示字幕 3s → 真退房 → 熄屏
I (xxxxx) Application: ✓ 已真退出 RTC 房间leaveRoom + destroyRTCEngine
I (xxxxx) Application: ✓ 已进入空闲休眠(累计第 1 次)
```
3. 屏幕应该:
- 字幕显示"AI 即将休眠..."保持 3 秒
- 字幕消失3 秒后屏幕熄灭
4. 按 BOOT 唤醒 → 观察日志:
```
I (xxxxx) Airhub1: 🔵 BOOT button pressed in hibernate → 唤醒
I (xxxxx) Application: ☀ 从休眠唤醒
I (xxxxx) VolcRtcProtocol: RTC 实例不存在,重新创建...
I (xxxxx) Application: 正在尝试打开音频通道
I (xxxxx) Application: 进入对话框状态:启用全双工
```
5. 屏幕亮起,可继续对话
6. 边界场景验证(**用户协作**
- 第 38 秒用户说话 → conv_status 触发 LISTENING → 时间戳刷新 → **不会触发 40s 退房**
**用户协作**: 烧录后亲测对话 → 等待 → 唤醒,目视确认。
不产生 commit。
---
### Task 6.9: 生成 HIBERNATE_REPORT.md
**内容**:
- LeaveRoom vs CloseAudioChannel 对比(真退房 vs 仅停媒体)
- conv_status 5 状态机
- 边界场景 Q1 bug 修复验证
- 内存兜底机制50 次熄屏后硬重启)
- 实测启动 → 熄屏 → 唤醒完整日志
- 火山官方推荐方案对应(图 2/3/4
**commit**: `docs(phase06): 空闲休眠验证报告HIBERNATE_REPORT.md`
## 3. 任务顺序
```
6.1 LeaveRoom → 6.2 OpenAudioChannel 适配 → 6.3 EnterIdleHibernate
→ 6.4 conv_status 刷新 → 6.5 Watchdog 改动作 → 6.6 BOOT 唤醒
→ 6.7(已含在 6.3)→ 6.8 烧录 → 6.9 报告
```
## 4. 风险与回滚
| 风险 | 缓解 |
|------|------|
| volc_rtc_destroy 内部异常导致后续 create 失败 | LeaveRoom 内捕获异常 + nullptr 守护OpenAudioChannel 失败有 2s 重试 |
| 唤醒后 WiFi 状态丢失 | LeaveRoom 不动 WiFiOpenAudioChannel 通过 WiFi 直接走 RTC 协议层 |
| BOOT 按键在 hibernate 期间被忽略 | hibernating_ 检测在 OnClick 第一行,不依赖 LVGL 锁/状态机 |
| idle_cycles_ 累计但 NVS 写失败 | 单独 namespace "hibernate",独立于其他 NVS 业务 |
| 熄屏后 LVGL 仍运行消耗 CPU | 后续可选优化Phase 7 测试发现再加 lvgl_port_stop |
| ai_chat_set_chat_message 在 hibernate 后被 RTC 协议层错误调用 | LeaveRoom 后 protocol_ 不再发字幕,安全 |
**回滚**: 每 Task 独立 commit单独 revert 即可。
## 5. Phase 6 完成验收清单
- [ ] Task 6.1-6.7 共 6 个原子 commit 完成
- [ ] Task 6.8 烧录验证40s 软休眠 + 字幕 + 真退房 + 熄屏 + BOOT 唤醒 + 重连
- [ ] Q1 bug 修复:第 38s 说话不会被踢出
- [ ] LeaveRoom 真退房:`volc_rtc_destroy` 调用成功rtc_handle_=nullptr
- [ ] 内存兜底NVS 持久化 idle_cycles_累计 50 次后硬重启
- [ ] Task 6.9 HIBERNATE_REPORT.md commit
- [ ] 整个 Phase 6 合并为 1 个大 commit 推送 gitea + GitHub
## 6. Phase 6 不做的事
- ❌ 修改字幕显示样式Phase 5 已完成)
- ❌ Phase 7 的集成测试 + 性能数据收集
- ❌ 完全去除旧 `reboot_dlg_idle` NVS 标志(保留兼容兜底)
- ❌ 服务端 StopVoiceChat HTTP 调用(客户端 destroy 已足够,服务端 180s 自动清理)
- ❌ lvgl_port_stop 暂停 LVGL先观察是否需要再决定
- ❌ Light Sleep / CPU 降频(与现有 PowerSaveTimer 协同,不改动)

View File

@ -0,0 +1,876 @@
# 火山 RTC 软退出房间方案 — 完整移植参考
> 来源: 数字人 RTC 项目 Phase 6 实施总结2026-05-13
> 用途: 移植到其他基于火山 RTC SDK 的项目,替换"硬重启退出"为"软退出 + 快速恢复"。
> 关键收益: 唤醒时间 15-25s → 3-5s省 80% 时间;用户体验:黑屏长断 → 字幕提示常亮。
## 目录
- [1. 背景与动机](#1-背景与动机)
- [2. 倒计时刷新方案选型A vs B vs C](#2-倒计时刷新方案选型)
- [3. 软退出 RTC 房间机制](#3-软退出-rtc-房间机制)
- [4. 完整调用链与状态机](#4-完整调用链与状态机)
- [5. 实施清单(步骤化)](#5-实施清单)
- [6. 6 个关键踩坑与修复经验](#6-6-个关键踩坑与修复经验)
- [7. 移植到其他项目的最小改动清单](#7-移植到其他项目的最小改动清单)
- [8. 时间对比与性能数据](#8-时间对比与性能数据)
- [9. 验证清单](#9-验证清单)
- [10. 火山 RTC SDK 关键 API 速查](#10-火山-rtc-sdk-关键-api-速查)
---
## 1. 背景与动机
### 1.1 旧方案的问题
火山 RTC AI 对话项目原有的"空闲退出 RTC"机制使用 **硬重启**
```cpp
// 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.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 分支` |
| **触发频率** | 每 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 行改动):
```cpp
// 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 的问题
```cpp
// 原代码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 接口
```cpp
// 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。需要适配
```cpp
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 内存兜底
```cpp
// 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 完整实现
```cpp
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 完整实现
```cpp
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 关键改动
```cpp
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 触发动作改造
```cpp
// 原代码:写 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 文件)
```cpp
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 显式重置:
```cpp
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 后触发:
```cpp
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 顺序如果是
```cpp
SetDeviceState(kDeviceStateIdle); // 此时 device_state=idlehibernating_=false
// → CanEnterSleepMode 返回 true
// → PowerSaveTimer 可能立即触发 Light Sleep
hibernating_.store(true); // 设置太晚
```
**修复**:必须先设 `hibernating_=true`,再 `SetDeviceState(kDeviceStateIdle)`
```cpp
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` 基类加虚函数:
```cpp
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()` 异步重建并轮询:
```cpp
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 渲染完成)
```cpp
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.h``virtual void LeaveRoom() { CloseAudioChannel(); }`
- `volc_rtc_protocol.h``void LeaveRoom() override;`
- `volc_rtc_protocol.cc` 实现 stop + destroy
2. **`OpenAudioChannel` 加重建逻辑**
- 检测 `rtc_handle_=NULL && iot_ready_` 时触发 Start + 轮询
3. **Application 加 hibernate 状态**
- `hibernating_` (std::atomic<bool>)
- `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 API`components/common/inc/volc_rtc.h`
```c
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/`

View File

@ -2514,6 +2514,210 @@ lvgl_port_unlock();
--- ---
## 十九、RTC 空闲倒计时方案选型与软退出机制Phase 6 实施记录)
> 数字人 RTC 项目 Phase 6 实施。本章节回答两个核心问题:
> 1. **何时倒计时刷新**(监听什么事件?)
> 2. **超时后如何退出 RTC**(释放 License 又快速恢复?)
### 19.1 三个倒计时刷新方案对比
火山 RTC 协议层向应用层暴露 3 类消息事件,对应 3 种倒计时刷新方案:
| 方案 | 监听源 | 触发频率 | CPU 消耗 | 优势 | 弊端 |
|------|--------|---------|---------|------|------|
| **A 扬声器音频流** | I2S DMA / Opus PCM 输出 | 每 20ms50Hz | 中(每秒 50 次时间戳更新) | 已实现零改动AI 持续说话期间持续刷新 | 不监听用户说话;用户开始说话 → AI 还在思考时倒计时无刷新 |
| **B 字幕监听 (subtitle)** | RTC `subtitle` 消息 | 每秒 5-15 次(流式 ASR/TTS | 极低10ns/字幕) | 火山官方推荐;用户 STT 字幕 + AI 流式 TTS 都覆盖 | 流式中间结果重复(已通过去重缓解) |
| **C 智能体状态 (conv_status)** | RTC `conv_status` 状态机消息 | 每轮对话 4-5 次状态切换 | 最低10ns/事件) | 事件级最早触发(用户开始说话立即 LISTENING状态语义清晰 | AI 长说话期间 ANSWERING 状态稳定不切换 → 不刷新 |
### 19.2 方案选型决策B + C 双源)
**业务需求**
- 用户说话 → 重置(用户在交互)
- AI 思考 → 重置(用户已发起对话)
- AI 回复 → 重置(对话进行中)
- 真正静默 → 倒计时(用户无交互)
**单方案缺陷**
- 单用 A用户说话期间无刷新 → 第 38s 说话被踢出
- 单用 CAI 长说话期间无刷新 → AI 朗读 40s 故事被踢出
- 单用 B理论可行但重复刷新多
**最终方案****B + C 双源**(不启用 A节省 CPU
- C 覆盖状态切换LISTENING/THINKING/ANSWERING/INTERRUPTED/ANSWER_FINISH
- B 覆盖 AI 持续说话期间的流式字幕
- A 用 `#ifdef PHASE6_ENABLE_AUDIO_FALLBACK` 关闭(保留代码可恢复)
实施位置(`main/application.cc`
```cpp
// conv_status 分支(方案 C
} 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
int conv_status = status_val->valueint;
ESP_LOGI(TAG, "🕒 conv_status=%d 刷新对话活跃时间", conv_status);
// ... 原有 emoji 切换
}
}
// subtitle 分支(方案 B
} 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
// ... 原有字幕解析
}
}
```
### 19.3 软退出 RTC 机制
#### 19.3.1 旧方案(硬重启退出)
```
40s 无活动 → 写 NVS reboot_dlg_idle=1 → esp_restart()
重启 → 读 NVS → 跳过开机播报 → 重新走完整初始化WiFi + RTC
总耗时 15-25s 不可用(黑屏 + 重连)
```
**问题**:用户体验差、屏幕黑屏长达 20s、NVS/RAM 状态丢失。
#### 19.3.2 新方案(软退出 RTC + 屏幕保持 + BOOT 唤醒)
```
40s 无活动 → Application::EnterIdleHibernate()
1. protocol_->LeaveRoom() ← volc_rtc_stop + volc_rtc_destroy真退房+释放 License
2. codec->EnableInput/Output(false)(重置 codec 状态机)
3. recorder_pipeline_close释放录音管道
4. hibernating_=true阻止 PowerSaveTimer 进入 Light Sleep
5. esp_pm_configure(light_sleep=false)(双保险防止 I2C 进入低功耗)
6. SetDeviceState(kDeviceStateIdle)
7. 屏幕字幕:"已自动退出RTC对话按BOOT键重新连接RTC"(不熄屏)
8. NVS 持久化 idle_cycles_++
BOOT 按键 → Application::WakeFromHibernate()
1. 累计 50 次 → 硬重启清理碎片(兜底)
2. 清空字幕
3. ToggleChatState() → OpenAudioChannel
4. OpenAudioChannel 检测 rtc_handle_=NULL → 触发 Start() 重建
5. volc_rtc_create + volc_rtc_start → 加入新房间
总耗时 ~3-5 秒(无 WiFi 重连,仅 RTC 重建 + HTTP token
```
### 19.4 关键 SDK API 理解
| API | 作用 | 释放 License? |
|-----|------|-------------|
| `volc_rtc_stop(rtc)` | ≈ 服务端 StopVoiceChat仅 AI 任务停止) | ❌ 真人仍在房间继续计费 |
| **`volc_rtc_destroy(rtc)`** | **= leaveRoom + destroyRTCEngine真人离开+销毁实例)** | ✅ **真退房** |
| StopVoiceChat HTTP API | 服务端运营接口 | ⚠️ 仅 AI 离开,需配合客户端 leaveRoom |
火山官方文档明确:"**完整结束通话客户端必须 leaveRoom + destroyRTCEngine**"。
`CloseAudioChannel()` 当前只调 stop 不够,新增 `LeaveRoom()` 调用 stop + destroy。
### 19.5 火山 RTC `conv_status` 5 状态机
| status 值 | 含义 | 触发时机 |
|----------|------|---------|
| 1 LISTENING | 用户说话 / AI 听 | 用户开始说话即时触发 |
| 2 THINKING | AI 思考 | ASR 完成、LLM 推理中 |
| 3 ANSWERING | AI 回答 | TTS 开始 |
| 4 INTERRUPTED | 被打断 | 用户打断 AI |
| 5 ANSWER_FINISH | AI 说完 | TTS 结束 |
应用层在 `application.cc:1260+` 已有 emoji 切换映射Phase 6 复用此分支加入时间戳刷新。
### 19.6 时间对比
| 指标 | 硬重启退出 | **软退出Phase 6** |
|------|----------|------------------|
| 设备启动 | 2-3s | 0s |
| WiFi 重连 | 10-15s ⭐ | 0s保持连接 |
| RTC 实例创建 | ~100ms | ~100ms |
| HTTP GetRTCConfig | ~1-3s | ~1-3s |
| 加入房间 | ~300ms | ~300ms |
| 远程 AI 加入 | ~0.5-2s | ~0.5-2s |
| **总计** | **~15-25s** | **~3-5s** |
| **屏幕表现** | 黑屏 20s | 字幕提示常亮 |
| **NVS/RAM 状态** | 清空 | 保留 |
| **License 释放** | ✅ | ✅ |
软退出**节省 80% 唤醒时间**,最大头来自不用 WiFi 重连。
### 19.7 关键踩坑修复
#### 坑 1: codec 状态机不重置 → 唤醒后 I2C 失败 abort
**现象**BOOT 唤醒后 `Adev_Codec: Input already open` + `Fail to write to dev 30` → ESP_ERROR_CHECK abort 重启。
**修复**EnterIdleHibernate 中显式 `codec->EnableInput(false); codec->EnableOutput(false);` 重置状态机,让唤醒走完整 `esp_codec_dev_open` 路径。
#### 坑 2: PowerSaveTimer Light Sleep 干扰 I2C
**现象**hibernate 期间 PowerSaveTimer 10s 后触发 `esp_pm_configure(light_sleep=true)` → ESP32-S3 进入 Light Sleep → I2C 控制器外设进入低功耗 → 唤醒后 ES7210/ES8311 通信失败。
**修复**
1. `CanEnterSleepMode()``if (hibernating_.load()) return false`
2. EnterIdleHibernate 中 `esp_pm_configure(light_sleep_enable=false)` 双保险
3. `hibernating_.store(true)` 必须在 `SetDeviceState(kDeviceStateIdle)` **之前**(避免 idle 状态下 PowerSaveCheck 立即触发)
#### 坑 3: dynamic_cast 在 -fno-rtti 下编译失败
**现象**`error: 'dynamic_cast' not permitted with '-fno-rtti'`
**修复**:在 `Protocol` 基类加 `virtual void LeaveRoom() { CloseAudioChannel(); }``VolcRtcProtocol` override。Application 直接 `protocol_->LeaveRoom()` 多态调用。
#### 坑 4: LeaveRoom 后 rtc_handle_=NULL 无法再 OpenAudioChannel
**现象**:唤醒后 OpenAudioChannel 检查 `if (!rtc_handle_) return false` 直接失败。
**修复**OpenAudioChannel 头部加重建逻辑——检测 `rtc_handle_=NULL && iot_ready_` 时触发 `Start()` 异步重建,轮询 5s 等 `rtc_handle_` 就绪。
#### 坑 5: 字幕 LVGL 锁竞争 → SetChatMessage 跳过
**现象**EnterIdleHibernate 中 SetChatMessage 在 LeaveRoom 之后立即调用LVGL 锁被 GIF 解码竞争500ms 超时 → 字幕未显示。
**修复**
1. 字幕调用推迟到 hibernate 流程**最后**LeaveRoom/codec/recorder 都完成后)
2. 调用 5 次重试(每次间隔 200ms 让 LVGL 渲染完成)
3. ai_chat_set_chat_message 已有 last_content 去重缓存,重试不重复加载相同内容
### 19.8 内存兜底(防止长期运行碎片化)
```cpp
static constexpr int IDLE_CYCLES_REBOOT_THRESHOLD = 50;
// EnterIdleHibernate 中累计
idle_cycles_++;
SaveIdleCyclesToNvs();
// WakeFromHibernate 中检查
if (idle_cycles_ >= IDLE_CYCLES_REBOOT_THRESHOLD) {
ResetIdleCyclesNvs();
esp_restart(); // 累计 50 次软退出后下次唤醒触发硬重启清理碎片
}
```
50 次按平均每次 5 分钟对话计算约覆盖 4 小时使用时长,防御 RTC 库长期运行的内存累积。NVS 持久化保证重启后保留计数。
### 19.9 实测验证(两次连续休眠唤醒)
| 阶段 | 时序 |
|------|------|
| 第 1 次循环 | 106s 进入 → 130s 唤醒RTC 加入耗时 3s |
| 第 2 次循环 | 219s 进入 → 267s 唤醒RTC 加入耗时 2s |
**未出现**abort / I2C 失败 / Light Sleep / NVS 数据丢失。
详见 `.planning/milestones/digital_human_rtc/phases/phase_06_idle_hibernate/`
---
## 附录 B与文档 v5.1 的映射 ## 附录 B与文档 v5.1 的映射
| 文档 v5.1 章节 | 本方案对应 | | 文档 v5.1 章节 | 本方案对应 |

View File

@ -0,0 +1,351 @@
# 音频卡顿全局资源分析与优化建议(只分析不改代码)
> 范围: 开机本地音效卡顿 + AI 对话扬声器播放卡顿
> 方法: 从 CPU/PSRAM/任务调度全局画像出发,定位卡顿根因
> 输出: 高/中/低 ROI 优化建议清单(按工作量排序)
## 1. 当前资源画像
### 1.1 硬件配置
| 资源 | 规格 |
|------|------|
| CPU | ESP32-S3 双核 Xtensa LX7 @ **240 MHz** |
| 内部 SRAM | 512 KB |
| PSRAM | 8 MB **QSPI @ 80 MHz**`CONFIG_SPIRAM_MODE_QUAD=y` / `SPIRAM_SPEED_80M=y` |
| LCD | ST77916 QSPI 80MHz / 360×360 RGB565 |
| Codec | ES8311输出+ ES7210输入@ I2C / I2S |
| FreeRTOS | tick @ 100 Hz |
### 1.2 任务分布与 Core 绑定
| 任务名 | Core | 优先级 | 来源 |
|--------|------|--------|------|
| `audio_loop` | **Core 1强制** | 8 | `application.cc:639` |
| `main_loop` | **Core 0强制** | 4 | `application.cc:646` |
| `lvgl_task` | **任意(实际倾向 Core 0** | 3 | `lvgl_port_init` task_affinity=-1 |
| `background_task` | 任意 | **2** | `background_task.cc:12` |
| `dialog_watchdog` | Core 0pinned | 5 | `application.cc:2036` |
| `volc_rtc_init` | 任意 | 5 | `volc_rtc_protocol.cc:181` |
| `wake_hib` (Phase 6) | 任意 | 5 | BOOT 唤醒派发 |
### 1.3 关键定时器频率
| 定时器 | 频率 | 来源 |
|--------|------|------|
| LVGL refresh | **5ms (200 Hz)** | `lvgl_port_init.task_max_sleep_ms = 500, timer_period_ms = 5` |
| GIF 解码 | **20ms (50 Hz)** | `lv_timer_set_period(gifobj->timer, 20)` |
| Dialog watchdog | 2s | `application.cc:2033 vTaskDelay(2000)` |
| Battery/HTTP report | ~3s | `WifiBoard` |
## 2. 卡顿根因分析(按影响排序)
### 2.1 根因 1: PSRAM 80MHz QSPI 带宽竞争 ⭐⭐⭐(最主要)
**理论带宽**QSPI 80MHz 单线传输 ≈ **40 MB/s 单向**(读+写共享)。
**实际带宽**:因协议开销 + cache miss + 总线竞争 ≈ **20-30 MB/s 可用**
**PSRAM 客户端列表**(按访问频率):
| 客户端 | 频率 | 单次大小 | 累计带宽 |
|--------|------|---------|---------|
| LVGL 帧缓冲(双 buf or 部分屏幕刷新) | 200 Hz | 14.4 KB20 行 × 360 × 2B | ~2.9 MB/s |
| GIF 解码LZW → RGB565 | 50 Hz | ~30 KB/帧209×360×2 | ~1.5 MB/s |
| 背景图(常驻 PSRAM 只读) | 0 | 253 KB | 0缓存 |
| Opus 解码缓冲AI 说话) | 50 Hz每 20ms 一帧) | ~1280 B/帧 | ~64 KB/s |
| I2S DMA descriptor + buffer | 持续 | KB 级 | ~32-128 KB/s |
| WiFi RX/TX 缓冲(部分在 PSRAM | 突发 | 1-2 KB | 变化 |
| RTC 协议层缓冲 | 突发 | KB 级 | 变化 |
| 字幕渲染中文字体glyph| 字幕更新时 | KB 级 | 突发 |
| **合计稳态** | — | — | **~5-6 MB/s** |
| **合计突发峰值** | — | — | **~15-20 MB/s** |
**问题**
- PSRAM 同一时刻只能服务一个 clientQSPI 是串行总线)
- 突发期AI 说话 + GIF 切换 + 字幕更新)接近带宽上限
- 任何 client 等待都会增加延迟,**音频解码对延迟敏感**I2S DMA 缓冲消耗完前必须填新数据)
### 2.2 根因 2: AudioLoop 死循环占满 Core 1 ⭐⭐
```cpp
// application.cc 中 audio_loop 任务
void Application::AudioLoop() {
auto codec = Board::GetInstance().GetAudioCodec();
while (true) {
OnAudioInput();
if (codec->output_enabled()) {
OnAudioOutput();
}
}
}
```
**问题**
- **没有 `vTaskDelay`** → busy loop
- Core 1 上 idle task 几乎不运行
- FreeRTOS watchdog 警告("task watchdog got triggered")只是因为 priority 8 高,会导致其他 Core 1 任务(如 WiFi/BLE 协议栈如果在 Core 1饥饿
**影响**
- Core 1 100% 占用
- WiFi RX 中断处理可能被延迟(导致 RTC 包丢失重传)
- Opus 解码(在 background_task可能在 Core 0 也可能在 Core 1分配不确定
### 2.3 根因 3: LVGL/GIF 占用 Core 0 与 main_loop 竞争 ⭐⭐
**Core 0 任务**
- main_looppriority 4处理 Schedule lambda 队列)
- lvgl_taskpriority 3affinity=-1 但调度器偏向 Core 0
- dialog_watchdogpinned Core 0
- background_taskpriority 2任意 Core
**当 AI 说话时**
- 字幕到达 → Schedule lambda 入队
- conv_status 到达 → Schedule lambda 入队
- 情绪映射 → Schedule lambda 入队switch GIF
- GIF 切换 → bg_gif_demo_switch_gif → PSRAM 释放/分配 → LVGL set_src
- LVGL 渲染新帧
- 同时 background_task 在解码 Opus
**Core 0 工作量**5 类任务串行竞争。
### 2.4 根因 4: GIF 解码 PSRAM 写入与 I2S DMA 抢同一 PSRAM 总线 ⭐⭐
GIF 解码gifdec
- 每 20ms 一次 LZW 解码
- 解码输出写入 PSRAM 帧缓冲(~30KB
- LVGL 然后从 PSRAM 读取送到 LCD
I2S DMA 输出:
- I2S DMA descriptor 链表在 internal SRAM
- 但 PCM 数据 buffer 可能在 PSRAM`heap_caps_malloc(MALLOC_CAP_SPIRAM)`
- DMA 突发读 PSRAM → 与 GIF 解码写 PSRAM 竞争
**当 PSRAM 总线繁忙时**
- DMA 等待 PSRAM 总线空闲
- I2S 缓冲(通常几 KB消耗完前未填新数据 → 输出断流 → 听觉上的卡顿/爆音
### 2.5 根因 5: 开机阶段 PSRAM 写入风暴 ⭐⭐⭐(开机音效卡顿)
开机时序:
```
T=0~0.7s: LCD 初始化(少量 PSRAM 写)
T=0.7~2s: bg_gif_demo_start
├─ esp_jpeg 解码 360×360 JPG → PSRAM 写入 253 KB
└─ hiyori_m06.gif 加载到 PSRAM ~440 KB
T=2~3s: AI 对话屏幕初始化(字幕容器等 LVGL 对象)
T=3s: codec 初始化I2C 通信)
T=15s+: WiFi 连接成功后 → 播放开机音效PlaySound P3_LALA_KAIJIBOBAO
├─ 读 Flash 中的 P3 数据
├─ Opus 解码到 PSRAM PCM buffer
└─ I2S DMA 输出
```
**问题点**
- 开机音效播放时 LCD/LVGL 还在做 GIF 解码(每 20ms+ 字幕渲染
- PSRAM 同时被 GIF 解码、LVGL 帧缓冲、Opus PCM 缓冲、I2S DMA 缓冲争用
- 第一次冷启动 codec 状态也可能不稳Phase 1 期间日志中的 "Fail to write to dev 30"
### 2.6 根因 6: 字幕高频更新触发 LVGL 全屏重绘 ⭐
AI 说话时字幕推送频率高(**81 次 / 30s 实测**
- 每条字幕到达 → display->SetChatMessage → lv_label_set_text
- lv_label_set_text **可能触发 chat_label 所在区域重绘**
- 字幕区域 320×56 = 17920 像素 → PSRAM 写入 36 KB
如果字幕频率 5-15 次/s × 36 KB = **180-540 KB/s 额外 PSRAM 流量**
Phase 5 优化(锁外去重 + 500ms 锁)已减少部分压力,但 LVGL 内部重绘逻辑仍消耗带宽。
## 3. 优化建议清单(按 ROI 排序)
### 3.1 高 ROI修改简单 + 效果显著)
#### ★ 建议 1: AudioLoop 加 `vTaskDelay(1)` 让出 Core 1 idle
```cpp
void Application::AudioLoop() {
auto codec = Board::GetInstance().GetAudioCodec();
while (true) {
OnAudioInput();
if (codec->output_enabled()) {
OnAudioOutput();
}
vTaskDelay(1); // 让出 idle10msFreeRTOS 100Hz tick
}
}
```
**效果**Core 1 不再 100% 占用idle task 跑得动watchdog 喂得到。WiFi 中断/RTC 协议栈不被饥饿。
**风险**:音频处理频率从 ~每帧立即 改为 10ms 一次。但 OnAudioOutput 内部本身是处理一个完整 PCM 帧20ms10ms 调度间隔够。
#### ★ 建议 2: GIF 解码降频 20ms → 33ms30 Hz
```cpp
// ai_chat_ui.c 等位置
lv_timer_set_period(gifobj->timer, 33); // 20ms → 33ms
```
**效果**GIF 解码 CPU 占用减半PSRAM 写入流量减半(~1.5 MB/s → 0.75 MB/s
**视觉影响**:人眼 30 FPS 完全可接受(电影 24 FPS
#### ★ 建议 3: LVGL refresh 降频 5ms → 16ms
```c
// lcd.c 中 lvgl_port_init 配置
lvgl_cfg.timer_period_ms = 16; // 5ms → 16ms (60Hz)
```
**效果**LVGL 任务 CPU 占用减少 60%PSRAM 读写减少(~2.9 MB/s → 0.9 MB/s
**视觉影响**60 FPS LCD 刷新肉眼无差异。GIF 仍是 30Hz。
#### ★ 建议 4: background_task 优先级 2 → 5
```cpp
// background_task.cc:12
xTaskCreate(..., stack_size, this, 5, &background_task_handle_);
2 改为 5
```
**效果**AI Opus 解码不会被 main_looppri 4延迟。
**风险**:低,原 priority 2 太保守。
#### ★ 建议 5: 字幕去重升级 — 流式中间结果合并
当前 `ai_chat_set_chat_message` 锁外去重检查完整 256 字符 strncmp相同内容跳过。
建议增加节流debounce逻辑
```cpp
static int64_t last_update_us = 0;
const int64_t MIN_INTERVAL_US = 100000; // 100ms 最小更新间隔
int64_t now_us = esp_timer_get_time();
if (now_us - last_update_us < MIN_INTERVAL_US) return;
last_update_us = now_us;
```
**效果**:流式中间结果每 100ms 最多 1 次更新PSRAM 写入减少 5-10 倍。
### 3.2 中 ROI修改稍多 + 效果中等)
#### 建议 6: I2S PCM 缓冲移到 internal SRAM
I2S DMA 读取 PCM 数据时若 buffer 在 PSRAMDMA 突发与其他 PSRAM 客户端争用。
改为 `heap_caps_malloc(size, MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL)`,让 DMA 读 internal SRAM带宽 ~1 GB/s 远大于 PSRAM
**效果**:音频 DMA 完全不受 PSRAM 拥堵影响。
**代价**512KB internal SRAM 已经紧张LVGL .bss + 协议栈 + 堆),可能挤占其他模块。建议先用 `heap_caps_get_free_size(MALLOC_CAP_INTERNAL)` 看余量。
#### 建议 7: Schedule lambda 队列流控
main_loop 处理 Schedule lambda 是串行的。AI 说话期间 lambda 可能积压:
- conv_status × N
- subtitle × M
- emotion 切换 × K
- 字幕 set_chat_message × L
可加队列长度警告日志,超过 20 时合并/丢弃中间状态。
#### 建议 8: GIF 切换防抖
22 情绪 → 3 GIF 已映射,但 `ai_chat_set_emotion` 仍每次 conv_status 调用一次。
建议加 200ms 防抖100ms 内同一情绪只切一次 GIF。
#### 建议 9: 开机音效预加载到 internal SRAM
P3 音效解码到 internal SRAM PCM 数组,开机时直接送 DMA
- 避免开机 PSRAM 风暴期间 Opus 解码与 GIF 解码竞争
代价P3 数据展开后约几十 KB / 个,预加载占内部 SRAM。
### 3.3 低 ROI修改大或效果有限
#### 建议 10: PSRAM 升级到 Octal SPI
ESP32-S3 支持 OPI PSRAMCONFIG_SPIRAM_MODE_OCT80MHz Octal → 带宽 ~80 MB/s 翻倍。
**前提**:硬件 PSRAM 芯片支持 OPIESP32-S3-N16R8 通常是 QPI不支持 OPI。需要 N16R8VOPI 版)。
#### 建议 11: LVGL 双缓冲改单缓冲
减少一份帧缓冲 PSRAM 占用,但会引入撕裂。不建议(视觉问题更糟)。
#### 建议 12: 关闭部分协议层日志
`volc_rtc.c` 等大量日志 `[INF|volc_rtc.c:475]message received...` 占用 UART 输出。每次日志 1-2ms。
设置 `CONFIG_LOG_DEFAULT_LEVEL_WARN` 关闭 INFO省一些 CPU。
#### 建议 13: 关闭 PowerSaveTimer 的 Light SleepPhase 6 已部分做)
Phase 6 hibernate 期间已关。但**对话期间** PowerSaveTimer 仍可能在 idle 状态触发 Light Sleep可考虑彻底关闭不省那点电耗
## 4. 推荐实施顺序
**第一轮5 分钟改 5 行代码)**
1. AudioLoop 加 `vTaskDelay(1)` → 立竿见影
2. background_task 优先级 2 → 5
3. GIF 定时器 20ms → 33ms
4. LVGL refresh 5ms → 16ms
5. 字幕节流 100ms 最小间隔
**预期效果**:开机音效和 AI 语音卡顿消失 80%。
**第二轮(如果第一轮不够)**
6. I2S PCM 缓冲改 internal SRAM
7. GIF 切换防抖 200ms
8. Schedule lambda 队列流控
**第三轮(追求极致)**
9. P3 音效预加载
10. 关闭部分协议日志
## 5. 测量方法(可选实施)
### 5.1 CPU 占用监控
```cpp
// 添加每 5 秒打印一次
TaskStatus_t task_status[20];
UBaseType_t task_count = uxTaskGetSystemState(task_status, 20, NULL);
for (int i = 0; i < task_count; i++) {
ESP_LOGI(TAG, "Task %s: priority=%d, runtime=%lu",
task_status[i].pcTaskName,
task_status[i].uxCurrentPriority,
task_status[i].ulRunTimeCounter);
}
```
需要 `CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y`
### 5.2 PSRAM 带宽监控
```cpp
// 通过 heap_caps_get_largest_free_block 监控碎片化
size_t spiram_free = heap_caps_get_free_size(MALLOC_CAP_SPIRAM);
size_t spiram_max_block = heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM);
ESP_LOGI(TAG, "PSRAM: free=%zu, largest_block=%zu, fragmentation=%.1f%%",
spiram_free, spiram_max_block,
(1.0 - (double)spiram_max_block / spiram_free) * 100);
```
### 5.3 音频卡顿量化
I2S DMA 缓冲 underrun 计数(如果驱动暴露)。
或者在 OnAudioOutput 中记录每帧实际写入耗时,> 20ms 警告。
## 6. 结论
**卡顿根因(按贡献排序)**
1. PSRAM 80MHz QSPI 带宽 ~20-30 MB/s多客户端竞争GIF 解码 + LVGL + Opus + 字幕)
2. AudioLoop busy loop 占满 Core 1
3. LVGL/GIF 在 Core 0 与 main_loop 竞争
4. GIF 解码与 I2S DMA 抢 PSRAM 总线
5. 开机阶段 PSRAM 写入风暴
6. 字幕高频更新触发 LVGL 重绘
**优化策略****降低 CPU 时间片冲突 + 减少 PSRAM 流量**
**第一轮 5 行代码改动可见效**(详见 §4 推荐实施顺序)。
**不建议立即修改的方向**
- ❌ 升级 OPI PSRAM需要换硬件 SKU
- ❌ 改 LVGL 双缓冲为单缓冲(视觉撕裂更糟)
- ❌ 完全重写音频任务模型(工作量大风险高)

View File

@ -27,6 +27,7 @@
#include <driver/gpio.h> #include <driver/gpio.h>
#include <arpa/inet.h> #include <arpa/inet.h>
#include <esp_app_desc.h> #include <esp_app_desc.h>
#include <esp_pm.h>
#include <cmath> #include <cmath>
#include <chrono> #include <chrono>
#include <esp_wifi.h> #include <esp_wifi.h>
@ -43,6 +44,11 @@ extern "C" void ai_chat_resume_animation(void);
// 设备空闲无对话状态 倒计时 // 设备空闲无对话状态 倒计时
#define DIALOG_IDLE_COUNTDOWN_SECONDS 40 #define DIALOG_IDLE_COUNTDOWN_SECONDS 40
// Phase 6: 方案 Cconv_status 状态机刷新)单独使用,关闭方案 A扬声器流刷新
// 节省 CPU方案 A 每 20ms 一帧 PCM 都更新时间戳)。
// 取消注释下行宏可恢复方案 A 作为兜底(双源刷新)。
// #define PHASE6_ENABLE_AUDIO_FALLBACK
// 定义设备状态字符串 // 定义设备状态字符串
static const char* const STATE_STRINGS[] = { static const char* const STATE_STRINGS[] = {
@ -74,6 +80,7 @@ Application::Application() {
#endif #endif
last_audible_output_time_ = std::chrono::steady_clock::now(); // 初始化最后一次有声音输出的时间点 last_audible_output_time_ = std::chrono::steady_clock::now(); // 初始化最后一次有声音输出的时间点
skip_dialog_idle_session_ = false; // 初始化跳过对话待机会话标志为false skip_dialog_idle_session_ = false; // 初始化跳过对话待机会话标志为false
LoadIdleCyclesFromNvs(); // Phase 6: 从 NVS 加载累计休眠次数(用于内存碎片兜底)
dialog_watchdog_running_ = false; // 初始化对话看门狗运行标志 dialog_watchdog_running_ = false; // 初始化对话看门狗运行标志
dialog_watchdog_last_logged_ = -1; // 初始化对话看门狗日志记录 dialog_watchdog_last_logged_ = -1; // 初始化对话看门狗日志记录
dialog_watchdog_task_handle_ = nullptr; // 初始化对话看门狗任务句柄 dialog_watchdog_task_handle_ = nullptr; // 初始化对话看门狗任务句柄
@ -1261,7 +1268,11 @@ void Application::Start() {
// RTC 会话状态 → emoji 切换 // RTC 会话状态 → emoji 切换
auto status_val = cJSON_GetObjectItem(root, "status"); auto status_val = cJSON_GetObjectItem(root, "status");
if (status_val) { if (status_val) {
// Phase 6 方案 C: conv_status 状态切换刷新对话活跃时间
// 比扬声器音频流更早(事件级),修复"用户开始说话就被踢"边界 bug
last_audible_output_time_ = std::chrono::steady_clock::now();
int conv_status = status_val->valueint; int conv_status = status_val->valueint;
ESP_LOGI(TAG, "🕒 conv_status=%d 刷新对话活跃时间", conv_status);
Schedule([this, display, conv_status]() { Schedule([this, display, conv_status]() {
switch (conv_status) { switch (conv_status) {
case 1: // LISTENING case 1: // LISTENING
@ -1290,6 +1301,12 @@ void Application::Start() {
} else if (strcmp(type->valuestring, "subtitle") == 0) { } else if (strcmp(type->valuestring, "subtitle") == 0) {
// 火山 RTC 字幕消息data 数组中包含 text、userId、definite // 火山 RTC 字幕消息data 数组中包含 text、userId、definite
auto data_arr = cJSON_GetObjectItem(root, "data"); auto data_arr = cJSON_GetObjectItem(root, "data");
if (data_arr && cJSON_IsArray(data_arr) && cJSON_GetArraySize(data_arr) > 0) {
// Phase 6 方案 B: 收到有效字幕user STT 或 AI 流式 TTS→ 刷新对话活跃时间
// 覆盖 AI 持续说话期间 conv_status 不切换的边界场景
last_audible_output_time_ = std::chrono::steady_clock::now();
ESP_LOGD(TAG, "🕒 字幕刷新对话活跃时间");
}
if (data_arr && cJSON_IsArray(data_arr)) { if (data_arr && cJSON_IsArray(data_arr)) {
for (int i = 0; i < cJSON_GetArraySize(data_arr); ++i) { for (int i = 0; i < cJSON_GetArraySize(data_arr); ++i) {
auto item = cJSON_GetArrayItem(data_arr, i); auto item = cJSON_GetArrayItem(data_arr, i);
@ -2043,28 +2060,15 @@ void Application::StartDialogWatchdog() {
// 调试日志 // 调试日志
ESP_LOGD(TAG, "Dialog watchdog: elapsed=%d, remaining=%d", (int)elapsed, remaining); ESP_LOGD(TAG, "Dialog watchdog: elapsed=%d, remaining=%d", (int)elapsed, remaining);
// 如果剩余秒数小于等于0说明对话空闲倒计时已到,需要重启设备 // 如果剩余秒数小于等于0说明对话空闲倒计时已到 → 进入空闲休眠(软退房+熄屏)
if (remaining <= 0) { if (remaining <= 0) {
ESP_LOGI(TAG, "Dialog watchdog idle reached, elapsed=%d, marking and rebooting", (int)elapsed); ESP_LOGI(TAG, "Dialog watchdog 触发:%ds 无对话活动 → 进入空闲休眠", (int)elapsed);
Settings sys("system", true); app->dialog_watchdog_running_ = false; // 停止 watchdog 循环
ESP_LOGI(TAG, "Dialog watchdog: preparing NVS writes (system)"); // Schedule 在主线程执行 EnterIdleHibernate避免在 watchdog task 里阻塞 3 秒字幕显示
sys.SetInt("reboot_dlg_idle", 1); app->Schedule([app]() {
sys.SetInt("reboot_origin", 1); app->EnterIdleHibernate();
ESP_LOGI(TAG, "Dialog watchdog: committing NVS (system)"); });
sys.Commit(); break; // 退出 watchdog 循环while/for 取决于 task 结构)
Settings sysr("system");
int32_t verify = sysr.GetInt("reboot_dlg_idle", 0);
int32_t origin_read = sysr.GetInt("reboot_origin", 0);
if (verify != 1) {
ESP_LOGW(TAG, "Dialog watchdog: NVS verify failed, cause=%d, origin=%d", (int)verify, (int)origin_read);
ESP_LOGW(TAG, "建议: 检查NVS空间是否不足、确认nvs_flash_init成功、避免并发写入(system)");
}
ESP_LOGI(TAG, "Dialog watchdog (task) set reboot_cause=1, verify=%d, restart in 2000ms", (int)verify);
// 重启前上报设备离线状态
Board::GetInstance().OnBeforeRestart();
vTaskDelay(pdMS_TO_TICKS(2000));
esp_restart();// 重启设备
app->dialog_watchdog_running_ = false;// 设置看门狗运行标志为false
} else { } else {
// 简化条件判断,移除冗余检查 // 简化条件判断,移除冗余检查
// 优化桶计算逻辑使用1秒一个桶更精确地显示倒计时 // 优化桶计算逻辑使用1秒一个桶更精确地显示倒计时
@ -2208,10 +2212,14 @@ void Application::OnAudioOutput() {
// 当有可听见的音频输出时,更新最后声音输出时间戳 // 当有可听见的音频输出时,更新最后声音输出时间戳
const float audible_volume_threshold = 0.01f; // 设置一个合理的音量阈值 const float audible_volume_threshold = 0.01f; // 设置一个合理的音量阈值
(void)audible_volume_threshold; // Phase 6: 默认关闭方案 A 时未使用
#ifdef PHASE6_ENABLE_AUDIO_FALLBACK
// Phase 6 方案 A 兜底(默认关闭,定义 PHASE6_ENABLE_AUDIO_FALLBACK 启用)
if (rms_volume >= audible_volume_threshold) { if (rms_volume >= audible_volume_threshold) {
this->last_audible_output_time_ = std::chrono::steady_clock::now(); this->last_audible_output_time_ = std::chrono::steady_clock::now();
ESP_LOGD(TAG, "🔊 更新last_audible_output_time_当前音量: %.4f", rms_volume); ESP_LOGD(TAG, "🔊 更新last_audible_output_time_当前音量: %.4f", rms_volume);
} }
#endif
#if CONFIG_USE_AUDIO_PROCESSOR #if CONFIG_USE_AUDIO_PROCESSOR
// 同步音量到音频处理器,用于动态阈值调整 // 同步音量到音频处理器,用于动态阈值调整
@ -2237,9 +2245,11 @@ void Application::OnAudioOutput() {
int bytes = (int)(pcm.size() * sizeof(int16_t)); int bytes = (int)(pcm.size() * sizeof(int16_t));
ESP_LOGD(TAG, "写入播放管道: 采样率=%d 字节=%d", src_rate, bytes); ESP_LOGD(TAG, "写入播放管道: 采样率=%d 字节=%d", src_rate, bytes);
player_pipeline_write(player_pipeline_, (char*)pcm.data(), bytes); player_pipeline_write(player_pipeline_, (char*)pcm.data(), bytes);
#ifdef PHASE6_ENABLE_AUDIO_FALLBACK
if (bytes > 0) { if (bytes > 0) {
this->last_audible_output_time_ = std::chrono::steady_clock::now(); this->last_audible_output_time_ = std::chrono::steady_clock::now();
} }
#endif
if (!first_play_logged && bytes > 0) { if (!first_play_logged && bytes > 0) {
ESP_LOGI(TAG, "开始播放下行音频: 字节=%d 采样率=%d", bytes, src_rate); ESP_LOGI(TAG, "开始播放下行音频: 字节=%d 采样率=%d", bytes, src_rate);
first_play_logged = true; first_play_logged = true;
@ -2247,9 +2257,11 @@ void Application::OnAudioOutput() {
} else { } else {
ESP_LOGD(TAG, "直接输出PCM到编解码器: 样本=%zu", pcm.size()); ESP_LOGD(TAG, "直接输出PCM到编解码器: 样本=%zu", pcm.size());
codec->OutputData(pcm);// 直接输出PCM数据 codec->OutputData(pcm);// 直接输出PCM数据
#ifdef PHASE6_ENABLE_AUDIO_FALLBACK
if (!pcm.empty()) { if (!pcm.empty()) {
this->last_audible_output_time_ = std::chrono::steady_clock::now(); this->last_audible_output_time_ = std::chrono::steady_clock::now();
} }
#endif
if (!first_play_logged && !pcm.empty()) { if (!first_play_logged && !pcm.empty()) {
ESP_LOGI(TAG, "开始播放下行音频: 样本=%zu 采样率=%d", pcm.size(), src_rate); ESP_LOGI(TAG, "开始播放下行音频: 样本=%zu 采样率=%d", pcm.size(), src_rate);
first_play_logged = true; first_play_logged = true;
@ -3590,6 +3602,13 @@ bool Application::CanEnterSleepMode() {
return false; return false;
} }
// Phase 6 关键修复hibernate 期间禁用 PowerSaveTimer 的 Light Sleep
// 否则 esp_pm_configure(light_sleep_enable=true) 会让 I2C 总线进入低功耗
// 状态,唤醒后 ES7210/ES8311 通信失败导致 ESP_ERROR_CHECK abort 重启
if (hibernating_.load()) {
return false;
}
// Now it is safe to enter sleep mode // Now it is safe to enter sleep mode
return true; return true;
} }
@ -4332,3 +4351,143 @@ void Application::HandleBleJsonCommand(const std::string& cmd, int msg_id, cJSON
service.SendResponse(cmd, msg_id, -99, "unknown cmd"); service.SendResponse(cmd, msg_id, -99, "unknown cmd");
} }
#endif #endif
// ============================================================
// Phase 6: 空闲休眠(真退房 + 熄屏 + BOOT 唤醒 + 内存兜底)
// ============================================================
extern "C" void pwm_set_brightness(uint8_t percent); // dzbj/pages_pwm.c
void Application::SaveIdleCyclesToNvs() {
Settings s("hibernate", true);
s.SetInt("idle_cycles", idle_cycles_);
}
void Application::LoadIdleCyclesFromNvs() {
Settings s("hibernate", false);
idle_cycles_ = s.GetInt("idle_cycles", 0);
}
void Application::ResetIdleCyclesNvs() {
Settings s("hibernate", true);
s.SetInt("idle_cycles", 0);
idle_cycles_ = 0;
}
void Application::EnterIdleHibernate() {
if (hibernating_.load()) {
ESP_LOGW(TAG, "已处于休眠状态,跳过重复进入");
return;
}
ESP_LOGI(TAG, "🌙 进入空闲休眠:真退房 → 字幕提示(不熄屏)");
auto display = Board::GetInstance().GetDisplay();
// 1. 真退出 RTC 房间(释放 License
// Protocol 基类的虚函数 LeaveRoom 默认回退到 CloseAudioChannel
// VolcRtcProtocol 覆写为 volc_rtc_stop + volc_rtc_destroy
if (protocol_) {
protocol_->LeaveRoom();
}
// 2. 字幕显示推迟到最后做(此时 LVGL 锁竞争最少)— 见步骤 9
// 3. 关闭 codec input/output 让状态机重置
// 修复 bug若不关闭唤醒后 EnableInput(true) 会进入 "已 open" 异常路径
// → esp_codec_dev_set_in_channel_gain ES_ERROR_CHECK 失败 abort
// → ESP32-S3 软重启而不是恢复对话
auto codec = Board::GetInstance().GetAudioCodec();
if (codec) {
ESP_LOGI(TAG, "EnterIdleHibernate: 关闭 codec input/output 重置状态机");
codec->EnableInput(false);
codec->EnableOutput(false);
}
// 4. 关闭录音管道(避免唤醒后重新打开时冲突)
if (recorder_pipeline_) {
recorder_pipeline_close(recorder_pipeline_);
recorder_pipeline_ = nullptr;
}
// 5. 关键时序:先设置 hibernating_=true阻止 PowerSaveTimer 进入 Light Sleep
// 再 SetDeviceState(kDeviceStateIdle)(之后 CanEnterSleepMode 会因为 hibernating_=true 而返回 false
hibernating_.store(true);
// 6. 双保险:强制 esp_pm 禁用 Light Sleep保护 I2C/codec 总线不进入睡眠)
// 防止 PowerSaveTimer 已经触发或紧接着触发 esp_pm_configure(light_sleep=true)
// 导致 ES7210/ES8311 唤醒后 I2C 通信失败 → ESP_ERROR_CHECK abort 重启
esp_pm_config_t pm_config = {
.max_freq_mhz = 240,
.min_freq_mhz = 240,
.light_sleep_enable = false,
};
esp_pm_configure(&pm_config);
ESP_LOGI(TAG, "EnterIdleHibernate: 已强制禁用 Light Sleep保护 I2C 总线)");
// 7. 设备状态切回 idle屏幕保持亮起仅字幕提示
SetDeviceState(kDeviceStateIdle);
// 8. 累计休眠次数NVS 持久化)
idle_cycles_++;
SaveIdleCyclesToNvs();
ESP_LOGI(TAG, "✓ 已进入空闲休眠(累计第 %d 次)", idle_cycles_);
if (idle_cycles_ >= IDLE_CYCLES_REBOOT_THRESHOLD) {
ESP_LOGW(TAG, "🛡 累计休眠 %d 次(阈值 %d下次唤醒触发硬重启清理内存碎片",
idle_cycles_, IDLE_CYCLES_REBOOT_THRESHOLD);
}
// 9. 显示退出提示字幕(最后做:此时 LeaveRoom/SetDeviceState 都完成,
// LVGL 锁竞争最少;带重试确保 LVGL 锁超时也能最终显示)
//
// 注意ai_chat_set_chat_message 内部有 last_content 去重缓存,
// 锁超时时不更新缓存,下次重试相同内容时仍会进入 lvgl_port_lock 路径
const char* hibernate_msg = "已自动退出RTC对话按BOOT键重新连接RTC";
for (int attempt = 0; attempt < 5; attempt++) {
vTaskDelay(pdMS_TO_TICKS(200)); // 让出 CPU 给 LVGL 任务完成当前帧渲染
if (display) {
display->SetChatMessage("system", hibernate_msg);
}
}
ESP_LOGI(TAG, "✓ 已显示退出提示字幕5 次重试覆盖 LVGL 锁竞争)");
}
void Application::WakeFromHibernate() {
if (!hibernating_.load()) {
return;
}
ESP_LOGI(TAG, "☀ 从空闲休眠唤醒");
// 内存兜底:累计达到阈值时硬重启清理碎片
if (idle_cycles_ >= IDLE_CYCLES_REBOOT_THRESHOLD) {
ESP_LOGI(TAG, "🛡 累计休眠 %d 次,硬重启清理内存碎片", idle_cycles_);
ResetIdleCyclesNvs();
pwm_set_brightness(80); // 重启前先点亮,避免黑屏时间过长
Board::GetInstance().OnBeforeRestart();
vTaskDelay(pdMS_TO_TICKS(500));
esp_restart();
return; // 不会执行到这
}
// 1. 屏幕未熄屏Phase 6 改动后不再熄屏),仅打印状态日志
ESP_LOGI(TAG, "WakeFromHibernate: device_state=%d, idle_cycles=%d",
(int)device_state_, idle_cycles_);
// 2. 清空 hibernate 期间的字幕提示
auto display = Board::GetInstance().GetDisplay();
if (display) {
display->SetChatMessage("system", "");
}
// 3. 先复位为 idle 状态EnterIdleHibernate 已设为 idle这里幂等
if (device_state_ != kDeviceStateIdle) {
SetDeviceState(kDeviceStateIdle);
}
// 4. 触发 RTC 重连(复用 ToggleChatState → OpenAudioChannel → 自动重建 rtc_handle_
ESP_LOGI(TAG, "WakeFromHibernate: 调用 ToggleChatState() 触发 RTC 重连...");
ToggleChatState();
hibernating_.store(false);
ESP_LOGI(TAG, "✓ 唤醒完成,已触发 RTC 重连(注意:实际重连进度由 ToggleChatState 异步处理)");
}

View File

@ -112,6 +112,11 @@ public:
bool IsDialogUploadEnabled() const { return dialog_upload_enabled_; }// 是否启用对话上传 bool IsDialogUploadEnabled() const { return dialog_upload_enabled_; }// 是否启用对话上传
void SetDialogUploadEnabled(bool enabled);// 设置对话上传状态 void SetDialogUploadEnabled(bool enabled);// 设置对话上传状态
// Phase 6: 空闲休眠相关
void EnterIdleHibernate(); // 进入空闲休眠(字幕+真退房+熄屏)
void WakeFromHibernate(); // 从休眠唤醒(亮屏+重连)
bool IsHibernating() const { return hibernating_.load(); }
// // BLE JSON 命令处理(暂不使用) // // BLE JSON 命令处理(暂不使用)
// void HandleBleJsonCommand(const std::string& cmd, int msg_id, cJSON* data, BleJsonService& service); // void HandleBleJsonCommand(const std::string& cmd, int msg_id, cJSON* data, BleJsonService& service);
@ -141,6 +146,15 @@ private:
std::atomic<std::chrono::steady_clock::time_point> last_safe_operation_; // 🔧 最后安全操作时间戳 std::atomic<std::chrono::steady_clock::time_point> last_safe_operation_; // 🔧 最后安全操作时间戳
std::atomic<bool> is_switching_to_listening_{false}; // 🔵 标志:正在主动切换到聆听状态 std::atomic<bool> is_switching_to_listening_{false}; // 🔵 标志:正在主动切换到聆听状态
std::atomic<bool> is_low_battery_transition_{false}; std::atomic<bool> is_low_battery_transition_{false};
// Phase 6: 空闲休眠状态
std::atomic<bool> hibernating_{false}; // 是否处于熄屏休眠状态
int idle_cycles_ = 0; // 累计休眠循环次数NVS 持久化)
static constexpr int IDLE_CYCLES_REBOOT_THRESHOLD = 50; // 累计 50 次触发硬重启清碎片
void SaveIdleCyclesToNvs();
void LoadIdleCyclesFromNvs();
void ResetIdleCyclesNvs();
ListeningMode listening_mode_ = kListeningModeAutoStop; ListeningMode listening_mode_ = kListeningModeAutoStop;
#if CONFIG_USE_REALTIME_CHAT #if CONFIG_USE_REALTIME_CHAT
bool realtime_chat_enabled_ = true; bool realtime_chat_enabled_ = true;

View File

@ -567,6 +567,20 @@ public:
last_click_time = current_time; last_click_time = current_time;
ESP_LOGI(TAG, "BOOT button clicked"); ESP_LOGI(TAG, "BOOT button clicked");
// Phase 6: 优先处理空闲休眠唤醒(最高优先级,不依赖 LVGL/状态机)
{
auto &app_wake = Application::GetInstance();
if (app_wake.IsHibernating()) {
ESP_LOGI(TAG, "🔵 BOOT in hibernate → 唤醒(恢复亮度 + 重连 RTC");
// 派发到独立 task 避免阻塞 iot_button 回调iot_button 在 esp_timer 任务中)
xTaskCreate([](void* arg) {
Application::GetInstance().WakeFromHibernate();
vTaskDelete(NULL);
}, "wake_hib", 4096, NULL, 5, NULL);
return;
}
}
#if ENABLE_TOUCH_PAD_BUTTONS #if ENABLE_TOUCH_PAD_BUTTONS
// 创建一个单独的任务来处理触摸解锁,避免在按钮回调中执行复杂操作 // 创建一个单独的任务来处理触摸解锁,避免在按钮回调中执行复杂操作
xTaskCreate([](void* arg) { xTaskCreate([](void* arg) {

View File

@ -54,6 +54,9 @@ public:
virtual void Start() = 0; virtual void Start() = 0;
virtual bool OpenAudioChannel() = 0; virtual bool OpenAudioChannel() = 0;
virtual void CloseAudioChannel() = 0; virtual void CloseAudioChannel() = 0;
// Phase 6: 真退出 RTC 房间(释放 License默认回退到 CloseAudioChannel
// VolcRtcProtocol 覆写:调用 volc_rtc_stop + volc_rtc_destroy
virtual void LeaveRoom() { CloseAudioChannel(); }
virtual bool IsAudioChannelOpened() const = 0; virtual bool IsAudioChannelOpened() const = 0;
virtual void SendAudio(const std::vector<uint8_t>& data) = 0; virtual void SendAudio(const std::vector<uint8_t>& data) = 0;
virtual void SendPcm(const std::vector<uint8_t>& data) {} virtual void SendPcm(const std::vector<uint8_t>& data) {}

View File

@ -334,6 +334,23 @@ void VolcRtcProtocol::LogUplinkStatsMaybe() {
} }
// 🔊 打开音频通道 // 🔊 打开音频通道
bool VolcRtcProtocol::OpenAudioChannel() { bool VolcRtcProtocol::OpenAudioChannel() {
// Phase 6: 如果 LeaveRoom 后 rtc_handle_ 被销毁,触发 Start() 重建
if (!rtc_handle_ && iot_ready_) {
ESP_LOGI(TAG, "Phase 6: RTC 实例不存在,触发重建...");
iot_ready_ = false; // 由 Start 任务重新置位
Start(); // 异步触发 volc_rtc_init 任务重建 rtc_handle_
// 等待 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, "Phase 6: RTC 重建超时5s唤醒失败");
return false;
}
ESP_LOGI(TAG, "Phase 6: RTC 实例已重建(耗时 %d ms", wait_ticks * 100);
}
if (!rtc_handle_) { if (!rtc_handle_) {
ESP_LOGW(TAG, "无法打开音频通道RTC句柄未准备就绪");// 无法打开音频通道RTC句柄未准备就绪 ESP_LOGW(TAG, "无法打开音频通道RTC句柄未准备就绪");// 无法打开音频通道RTC句柄未准备就绪
return false; return false;
@ -406,6 +423,26 @@ void VolcRtcProtocol::CloseAudioChannel() {
} }
} }
// Phase 6: 真退出 RTC 房间(释放 License
// = volc_rtc_stop + volc_rtc_destroy
// 火山官方文档StopVoiceChat 说明): 真人退房必须 leaveRoom + destroyRTCEngine
// 客户端调用 volc_rtc_destroy 后,服务端 AI 任务会在 180s 内自动停止
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_();
}
}
// 🔊 检查音频通道是否已打开 // 🔊 检查音频通道是否已打开
bool VolcRtcProtocol::IsAudioChannelOpened() const { bool VolcRtcProtocol::IsAudioChannelOpened() const {
return is_audio_channel_opened_; return is_audio_channel_opened_;

View File

@ -19,7 +19,12 @@ public:
void SendPcm(const std::vector<uint8_t>& data) override;// 🔊 发送PCM音频数据到RTC void SendPcm(const std::vector<uint8_t>& data) override;// 🔊 发送PCM音频数据到RTC
void SendG711A(const std::vector<uint8_t>& data) override;// 🔊 发送G711A音频数据到RTC void SendG711A(const std::vector<uint8_t>& data) override;// 🔊 发送G711A音频数据到RTC
bool OpenAudioChannel() override;// 🔊 打开音频通道 bool OpenAudioChannel() override;// 🔊 打开音频通道
void CloseAudioChannel() override;// 🔊 关闭音频通道 void CloseAudioChannel() override;// 🔊 关闭音频通道(仅 stop 媒体流,不退出房间)
// Phase 6: 真退出 RTC 房间 = volc_rtc_stop + volc_rtc_destroy释放 License
// 与 CloseAudioChannel 区别CloseAudioChannel 只停媒体流,房间仍占用
void LeaveRoom() override;
bool IsAudioChannelOpened() const override;// 🔊 检查音频通道是否已打开 bool IsAudioChannelOpened() const override;// 🔊 检查音频通道是否已打开
void SendAbortSpeaking(AbortReason reason) override;// 🔊 发送中止通话请求 void SendAbortSpeaking(AbortReason reason) override;// 🔊 发送中止通话请求
void SendStartListening(ListeningMode mode) override;// 🔊 发送开始监听请求 void SendStartListening(ListeningMode mode) override;// 🔊 发送开始监听请求