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:
parent
f2be9922b6
commit
b8a5fe958f
@ -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 | ⏳ 待启动 |
|
||||||
|
|||||||
@ -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-5s(vs 旧硬重启 15-25s,省 80%)
|
||||||
|
- ✅ 屏幕保持 + 字幕提示
|
||||||
|
- ✅ B+C 双源覆盖完整对话场景
|
||||||
|
- ✅ 6 个踩坑全部修复
|
||||||
|
- ✅ 完整移植参考文档可用于其他火山 RTC 项目
|
||||||
|
|
||||||
|
**Phase 6 完成,准备进入 Phase 7(集成测试 + 验证)或卡顿优化。**
|
||||||
@ -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 不动 WiFi,OpenAudioChannel 通过 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 协同,不改动)
|
||||||
876
docs/Rtc_AIavatar/RTC软退出方案_移植参考.md
Normal file
876
docs/Rtc_AIavatar/RTC软退出方案_移植参考.md
Normal 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 分支` |
|
||||||
|
| **触发频率** | 每 20ms(50 Hz) | 每秒 5-15 次 | 每轮对话 4-5 次 |
|
||||||
|
| **CPU 增量** | 中(每秒 50 次 chrono::now) | 极低(10ns × 5-15) | 最低(10ns × 4-5) |
|
||||||
|
| **代码改动** | 0(已实现) | 1 行 | 1 行 |
|
||||||
|
| **覆盖场景:用户开始说话** | ❌ AI 还没回应时无刷新 | ⚠️ 等 STT 出字幕(1-3s 延迟) | ✅ **立即触发 LISTENING** |
|
||||||
|
| **覆盖场景:AI 思考期** | ❌ 无音频输出 | ⚠️ 等字幕送达 | ✅ THINKING 触发 |
|
||||||
|
| **覆盖场景:AI 持续说话** | ✅ 持续 PCM 输出 | ✅ 流式字幕持续 | ❌ **ANSWERING 状态稳定不切换** |
|
||||||
|
| **覆盖场景:AI 长回答(>40s)** | ✅ | ✅ | ❌ |
|
||||||
|
|
||||||
|
### 2.3 选型结论:B + C 双源(不启用 A)
|
||||||
|
|
||||||
|
**单方案缺陷**:
|
||||||
|
- 单用 A:用户开始说话时不刷新 → 第 38s 说话被踢出
|
||||||
|
- 单用 B:字幕送达有 1-3s 延迟 → 用户开始说话与 AI 响应窗口空缺
|
||||||
|
- 单用 C:AI 长说话期间 ANSWERING 状态不切换 → 倒计时无刷新
|
||||||
|
|
||||||
|
**最优组合 B+C**:
|
||||||
|
- C 处理状态机切换(最早响应用户开始说话事件)
|
||||||
|
- B 处理流式字幕(最稳定的对话进行中刷新)
|
||||||
|
- A 关闭(避免每 20ms 时间戳更新的 CPU 消耗)
|
||||||
|
|
||||||
|
**实施代码**(1+1 行改动):
|
||||||
|
|
||||||
|
```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()` override:stop + destroy |
|
||||||
|
| `main/protocols/volc_rtc_protocol.cc` | `OpenAudioChannel` 头部加重建逻辑 |
|
||||||
|
| `main/application.h` | 新增 `hibernating_` / `idle_cycles_` / `EnterIdleHibernate` / `WakeFromHibernate` 等接口 |
|
||||||
|
| `main/application.cc` | 新增 NVS 持久化 + EnterIdleHibernate / WakeFromHibernate + CanEnterSleepMode 加 hibernating 检查 |
|
||||||
|
| `main/application.cc` | conv_status 分支 + subtitle 分支各加 1 行刷新(方案 B+C) |
|
||||||
|
| `main/application.cc` | Dialog Watchdog 触发动作从 `esp_restart` 改为 `Schedule(EnterIdleHibernate)` |
|
||||||
|
| `main/application.cc` 顶部 | 加 `// #define PHASE6_ENABLE_AUDIO_FALLBACK` + 用 `#ifdef` 包裹方案 A 3 处更新 |
|
||||||
|
| `main/boards/<board>.cc` | BOOT 按键回调入口加 `if (IsHibernating()) WakeFromHibernate()` |
|
||||||
|
|
||||||
|
### 5.2 关键代码模板
|
||||||
|
|
||||||
|
#### 5.2.1 EnterIdleHibernate 完整实现
|
||||||
|
|
||||||
|
```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=idle,hibernating_=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 Sleep(WiFi PSM)的情况
|
||||||
|
|
||||||
|
WiFi PSM 不影响 I2C 总线,可正常使用。
|
||||||
|
但建议 hibernate 期间通过 `Board::SetPowerSaveMode(false)` 暂时禁用,避免 WiFi DTIM 与 RTC 重连冲突。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 时间对比与性能数据
|
||||||
|
|
||||||
|
### 8.1 唤醒时间分解(实测)
|
||||||
|
|
||||||
|
| 阶段 | 硬重启退出 | 软退出 | 差异 |
|
||||||
|
|------|----------|--------|------|
|
||||||
|
| 设备启动(bootloader + 应用初始化) | 2-3s | 0s | ✅ -3s |
|
||||||
|
| WiFi 重连(NVS 凭据连接 AP) | **10-15s** ⭐ | 0s(保持连接) | ✅ **-15s** |
|
||||||
|
| RTC 实例创建(`volc_rtc_create`) | ~100ms | ~100ms | — |
|
||||||
|
| HTTP GetRTCConfig 获取 token | ~1-3s | ~1-3s | — |
|
||||||
|
| 加入房间(`byte_rtc_join_room`) | ~300ms | ~300ms | — |
|
||||||
|
| 远程 AI 加入(`bot_message`) | ~0.5-2s | ~0.5-2s | — |
|
||||||
|
| **总计** | **~15-25s** | **~3-5s** | **-80%** |
|
||||||
|
|
||||||
|
### 8.2 资源消耗
|
||||||
|
|
||||||
|
| 指标 | 数值 |
|
||||||
|
|------|------|
|
||||||
|
| 方案 B 字幕监听 CPU 增量 | ~10ns/字幕 × 10/s = 100ns/s(**<0.001%**) |
|
||||||
|
| 方案 C 状态监听 CPU 增量 | ~10ns/事件 × 5/对话 ≈ 0 |
|
||||||
|
| hibernating_ 检查 CPU 增量 | 0(每次 PowerSaveCheck 1 次原子加载) |
|
||||||
|
| 内存增量 | hibernating_ (1B) + idle_cycles_ (4B) + last_content[256] = ~261B |
|
||||||
|
| NVS 增量 | namespace `hibernate` 1 个 int32 key |
|
||||||
|
|
||||||
|
### 8.3 状态保留对比
|
||||||
|
|
||||||
|
| 状态 | 硬重启 | 软退出 |
|
||||||
|
|------|--------|--------|
|
||||||
|
| WiFi 凭据 | ✅ NVS 保留 | ✅ |
|
||||||
|
| 设备配置(音量/亮度等) | ✅ NVS 保留 | ✅ |
|
||||||
|
| 火山 RTC device_secret | ✅ NVS 缓存 | ✅ |
|
||||||
|
| RAM 中的对话历史 | ❌ 清空 | ✅ **保留** |
|
||||||
|
| LVGL UI 状态 | ❌ 重新加载 | ✅ **保留** |
|
||||||
|
| 数字人 GIF 解码缓存 | ❌ 重新加载 | ✅ **保留** |
|
||||||
|
| OTA 升级状态 | ✅ otadata 保留 | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 验证清单
|
||||||
|
|
||||||
|
### 9.1 功能验证
|
||||||
|
|
||||||
|
- [ ] 40s 无活动自动进入软休眠(字幕显示 + 屏幕保持)
|
||||||
|
- [ ] 用户说话 38s 不被踢出(方案 C LISTENING 触发)
|
||||||
|
- [ ] AI 长回答 50s 不被踢出(方案 B 字幕持续刷新)
|
||||||
|
- [ ] BOOT 按键唤醒 → 3-5s 内 RTC 重连完成
|
||||||
|
- [ ] 唤醒后正常进行新一轮对话
|
||||||
|
- [ ] 连续 5+ 次软休眠 + 唤醒循环无异常
|
||||||
|
|
||||||
|
### 9.2 异常验证
|
||||||
|
|
||||||
|
- [ ] 唤醒后无 `Fail to write/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/`)
|
||||||
@ -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 输出 | 每 20ms(50Hz) | 中(每秒 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 说话被踢出
|
||||||
|
- 单用 C:AI 长说话期间无刷新 → 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 章节 | 本方案对应 |
|
||||||
|
|||||||
351
docs/Rtc_AIavatar/音频卡顿_全局资源分析.md
Normal file
351
docs/Rtc_AIavatar/音频卡顿_全局资源分析.md
Normal 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 0(pinned) | 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 KB(20 行 × 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 同一时刻只能服务一个 client(QSPI 是串行总线)
|
||||||
|
- 突发期(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_loop(priority 4,处理 Schedule lambda 队列)
|
||||||
|
- lvgl_task(priority 3,affinity=-1 但调度器偏向 Core 0)
|
||||||
|
- dialog_watchdog(pinned Core 0)
|
||||||
|
- background_task(priority 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); // 让出 idle,10ms(FreeRTOS 100Hz tick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**效果**:Core 1 不再 100% 占用,idle task 跑得动,watchdog 喂得到。WiFi 中断/RTC 协议栈不被饥饿。
|
||||||
|
**风险**:音频处理频率从 ~每帧立即 改为 10ms 一次。但 OnAudioOutput 内部本身是处理一个完整 PCM 帧(20ms),10ms 调度间隔够。
|
||||||
|
|
||||||
|
#### ★ 建议 2: GIF 解码降频 20ms → 33ms(30 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_loop(pri 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 在 PSRAM,DMA 突发与其他 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 PSRAM(CONFIG_SPIRAM_MODE_OCT)80MHz Octal → 带宽 ~80 MB/s 翻倍。
|
||||||
|
**前提**:硬件 PSRAM 芯片支持 OPI(ESP32-S3-N16R8 通常是 QPI,不支持 OPI)。需要 N16R8V(OPI 版)。
|
||||||
|
|
||||||
|
#### 建议 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 Sleep(Phase 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 双缓冲为单缓冲(视觉撕裂更糟)
|
||||||
|
- ❌ 完全重写音频任务模型(工作量大风险高)
|
||||||
@ -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: 方案 C(conv_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 异步处理)");
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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) {}
|
||||||
|
|||||||
@ -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_;
|
||||||
|
|||||||
@ -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;// 🔊 发送开始监听请求
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user