perf(rtc-only): Phase 6 收尾 - 卡顿优化 + PowerSaveTimer 守卫 + 开机加速

代码改动:
- AudioLoop 加 vTaskDelay(1),让出 Core 1 idle task 防 WiFi/RTC 饥饿
- BackgroundTask 优先级 2 → 5,提升 Opus 解码实时性
- LVGL 刷新 5ms → 16ms (60Hz),CPU 占用降 60%
- GIF 定时器 20ms → 33ms (3 处),PSRAM 流量减半
- AI 字幕推送 100ms 节流,避免 LVGL 锁争抢
- EnterIdleHibernate 清空 audio_decode_queue_,防 standby_sound 残留误触发首帧
- PowerSaveTimer OnEnterSleepMode 加 device_state 守卫,拦截 dialog/connecting
  期间关功放(修复欢迎语期间被静音 bug)
- 取消开机 ADC 阻塞采样,开机播报响应从 6 秒缩到 < 3 秒

新增规划:
- Phase 7 占位文档:电量保护 + PowerSaveTimer 重构 + 唤醒杂音根治 + RTC 抖动缓解

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rdzleo 2026-05-14 11:38:48 +08:00
parent b8a5fe958f
commit 4b7b1949d4
7 changed files with 161 additions and 26 deletions

View File

@ -0,0 +1,97 @@
# Phase 7电量保护 + 低功耗管理重构
## 背景
Phase 6 在调试唤醒杂音过程中,暴露出三个**历史代码的耦合问题**,它们彼此牵连影响 UX
1. **开机电量保护**[application.cc:614](../../../../main/application.cc#L614) 原 618-630
- 同步采样 20 × 10 × 10ms = **6 秒阻塞**才能进入开机播报
- 电量 ≤ 25% 直接 `SetOutputVolumeRuntime(0)` 静音,没有 UI 提示
- 无屏 UI 阶段的遗留设计(防止低电压下功放产生噪声)
- **Phase 6 已临时禁用**,恢复开机响应速度
2. **PowerSaveTimer 在 dialog/connecting 状态错误关闭功放**
- PowerSaveCheck 状态机 `in_sleep_mode_` 翻转有边角 bugWakeUp 重置 ticks 但 `in_sleep_mode_` 残留为 true 的路径
- 历史症状:欢迎语期间 PowerSaveTimer 触发 OnEnterSleepMode → `codec->EnableOutput(false)` → 听不到欢迎语
- **Phase 6 已加 device_state 守卫拦截**[movecall_moji_esp32s3.cc:259](../../../../main/boards/movecall-moji-esp32s3/movecall_moji_esp32s3.cc#L259)),但只是补丁,根因未除
3. **PowerSaveCheck callback 外的 esp_pm_configure 不受守卫保护**
- [power_save_timer.cc:65](../../../../main/boards/common/power_save_timer.cc) callback 后无条件下发 `light_sleep_enable=true`
- 即使守卫拦截关功放I2C/I2S 总线仍可能因 Light Sleep 被掐
- 历史症状:唤醒后 codec 通信失败 / I2S DMA 卡死
## 目标
把上述三块**重构成一个连贯系统**,而非局部打补丁:
- 异步 + 增量电量监测,移除开机阻塞
- 屏幕 UI 低电提示(图标 + 文案),替代粗暴静音
- 分级低电策略(>25% 正常 / 15-25% 降音量 / <15% UI 警告 / <5% 强制 idle
- PowerSaveTimer 状态机重写,根本性解决 `in_sleep_mode_` 边角
- esp_pm_configure 调用统一收口到 callback 内部,受 device_state 守卫保护
## 范围(暂定,进入 Phase 7 时细化)
### 7.1 异步电量监测
- 后台 FreeRTOS task 定时(如 5s 一次ADC 采样,更新 `battery_level_` 原子变量
- `GetBatteryLevel()` 立即返回缓存值,开机首次返回 100% 或上次 NVS 持久化值
- 开机播报不再被电池采样阻塞
### 7.2 屏幕低电 UI
- 顶部状态栏电量图标(已有 LVGL 框架支持)
- ≤15% 弹窗"电量不足,请充电",但**不静音**,让用户主动响应
- ≤5% 才强制进入 idle配合 Phase 6 hibernate 流程退出 RTC 房间
### 7.3 PowerSaveTimer 状态机重写
- 用清晰的 4 态机ACTIVE / DIMMING / SLEEPING / WAKING
- `WakeUp()` 同时清 `ticks_``in_sleep_mode_`,消除"已睡未标记"路径
- `OnEnterSleepMode` 内部统一调用 `esp_pm_configure`,被 device_state 守卫保护
- 与 Phase 6 hibernate 状态机协同(不重复进入 sleep
### 7.4 PA 启停时机 / 唤醒杂音根治
- PowerSaveTimer/hibernate 都不应在 dialog 期间关 codec/PA
- 唤醒后 codec EnableOutput → 真实 PCM 到达约有 1 秒空窗I2S 跑空 DMA → 杂音
- 候选方案:
- 推迟 EnableOutput(true) 到 OnIncomingAudio 首帧(彻底消除空窗)
- GPIO PA 推迟到首帧 PCM 入队(事件驱动,不用 ramp
- 用 codec 软静音但**不启用 DAC ramp**(避免之前 23s 爬升副作用),首帧瞬时解
- 多方案对比并实测后再决定
### 7.5 RTC 抖动缓解(音质优化)
- **下行音频编码 G.711A → Opus**
- 当前 G.711A = 64 kbps对丢包无 FEC 保护
- Opus 16 kbps 自带 FEC + DTX抗丢包/带宽降 4 倍
- 需要服务端配合切换编解码器
- **Jitter buffer target 调整**100ms → 200-300ms
- 用更多缓冲延迟换抗抖动能力
- 实测当前 buffer_ms 经常被自适应拉到 240-440ms目标 100ms 偏低
- **Adaptive jitter buffer**:根据近 10s reor/expand_loss 动态调整 target
- 评估指标reor 降到 < 200expand_loss 降到 < 5/2 秒为达标
## 当前临时状态(进入 Phase 7 前)
| 模块 | 临时方案 | 长期方案 |
|---|---|---|
| 开机电量保护 | application.cc 注释,直接用 NVS 音量 | Phase 7.1 + 7.2 |
| PowerSaveTimer 误关功放 | board.cc OnEnterSleepMode 加 device_state 守卫 | Phase 7.3 |
| 唤醒杂音 | 已知短板,~1s 杂音用户可接受 | Phase 7.4 |
| 下行音频抖动 | 接受 reor 700-1800 / expand_loss 20-130 的现状 | Phase 7.5 |
| hibernate 队列残留 | EnterIdleHibernate 清空 audio_decode_queue_ | 保留 |
## 输入文档
- [Phase 6 PLAN.md](../phase_06_idle_hibernate/PLAN.md) - hibernate 流程
- [Phase 6 HIBERNATE_REPORT.md](../phase_06_idle_hibernate/HIBERNATE_REPORT.md) - 实施记录
- [音频卡顿_全局资源分析.md](../../../../docs/Rtc_AIavatar/音频卡顿_全局资源分析.md)
- 本次调试笔记(待补充):唤醒杂音 → soft ramp 副作用 → 回退教训
## 触发条件
进入 Phase 7 的前置条件:
- [ ] Phase 6 hibernate 稳定运行 ≥ 1 周无回归
- [ ] 用户体验确认开机/休眠/唤醒流程顺畅
- [ ] 决定是否同步实现电量 UI依赖屏幕设计稿
## 状态
🟡 **占位中** - 等待 Phase 6 稳定后启动正式规划。

View File

@ -611,23 +611,18 @@ void Application::Start() {
uplink_resampler_.Configure(16000, 8000);
codec->Start();
}
{
int battery_level = 0;
bool charging = false;
bool discharging = false;
if (board.GetBatteryLevel(battery_level, charging, discharging)) {
// 如果电池电量低于25%则将输出音量设置为0静音
if (battery_level <= 25) {
codec->SetOutputVolumeRuntime(0);
} else {
Settings s("audio", false);
int vol = s.GetInt("output_volume", AudioCodec::default_output_volume());
if (vol <= 0) {
vol = AudioCodec::default_output_volume();
}
codec->SetOutputVolumeRuntime(vol);// 设置运行时输出音量
}
// ⚠️ 开机电量保护逻辑临时禁用Phase 7 重构)
// 原设计:开机同步采样 20×10×10ms ADC 数据 → 电量≤25% 时强制静音
// 问题:阻塞 6 秒才能播放开机播报,且阈值粗暴无 UI 提示
// 临时方案:跳过阻塞采样,直接读 NVS 音量设置,恢复开机响应速度
// 长期方案:见 .planning/milestones/digital_human_rtc/phases/phase_07_battery_psm/
{
Settings s("audio", false);
int vol = s.GetInt("output_volume", AudioCodec::default_output_volume());
if (vol <= 0) {
vol = AudioCodec::default_output_volume();
}
codec->SetOutputVolumeRuntime(vol);
}
// // 在启动阶段创建并运行播放管道以统一输出(开机启动播放管道)
@ -2023,6 +2018,10 @@ void Application::AudioLoop() {
if (codec->output_enabled()) {
OnAudioOutput();
}
// 卡顿优化 1: 让出 Core 1 idle taskFreeRTOS 100Hz tick = 10ms
// 避免 busy loop 占满 Core 1防止 WiFi 中断/RTC 协议栈饥饿
// OnAudioInput/Output 内部本身处理一个完整 PCM 帧20ms10ms 调度间隔够
vTaskDelay(1);
}
}
@ -4383,26 +4382,42 @@ void Application::EnterIdleHibernate() {
auto display = Board::GetInstance().GetDisplay();
// [废弃方案] 静音填充曾尝试在此处用 codec->OutputData 填 200ms 静音覆盖 DMA 残留
// 但实测会让 ES7210 codec 进入卡死状态(连续 10 次重启 ES7210 I2C Open fail
// 移除该方案,杂音问题需要用其他方式解决(如降低唤醒后初始音量)
// 1. 真退出 RTC 房间(释放 License
// Protocol 基类的虚函数 LeaveRoom 默认回退到 CloseAudioChannel
// VolcRtcProtocol 覆写为 volc_rtc_stop + volc_rtc_destroy
// 注意LeaveRoom 内部会触发 on_audio_channel_closed_ 回调 → codec EnableOutput(false)
if (protocol_) {
protocol_->LeaveRoom();
}
// 2. 字幕显示推迟到最后做(此时 LVGL 锁竞争最少)— 见步骤 9
auto codec = Board::GetInstance().GetAudioCodec();
// 3. 关闭 codec input/output 让状态机重置
// 3. 字幕显示推迟到最后做(此时 LVGL 锁竞争最少)— 见步骤 9
// 4. 显式关闭 codec input/output 让状态机重置(回调可能已关 output这里幂等 + 关 input
// 修复 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);
}
// 3.5. 清空音频解码队列:阻止 hibernate 之前残留的 standby_sound / AI 半句 PCM
// 在唤醒后的 OnAudioOutput 中被错误"首帧"识别,从而把软静音过早解开。
{
std::lock_guard<std::mutex> lock(mutex_);
if (!audio_decode_queue_.empty()) {
ESP_LOGI(TAG, "EnterIdleHibernate: 清空残留音频队列 size=%zu",
audio_decode_queue_.size());
audio_decode_queue_.clear();
}
}
// 4. 关闭录音管道(避免唤醒后重新打开时冲突)
if (recorder_pipeline_) {
recorder_pipeline_close(recorder_pipeline_);

View File

@ -6,10 +6,12 @@
#define TAG "BackgroundTask"
BackgroundTask::BackgroundTask(uint32_t stack_size) {
// 卡顿优化 2: priority 2 → 5
// 避免 AI Opus 解码被 main_looppri 4延迟提升音频实时性
xTaskCreate([](void* arg) {
BackgroundTask* task = (BackgroundTask*)arg;
task->BackgroundTaskLoop();
}, "background_task", stack_size, this, 2, &background_task_handle_);
}, "background_task", stack_size, this, 5, &background_task_handle_);
}
BackgroundTask::~BackgroundTask() {

View File

@ -257,6 +257,15 @@ public:
// 创建 PowerSaveTimer仅 AI 模式需要)
power_save_timer_ = new PowerSaveTimer(240, 10, -1);
power_save_timer_->OnEnterSleepMode([this]() {
// 门禁CanEnterSleepMode 已要求 idle但 PowerSaveTimer 状态机存在
// "in_sleep_mode_ 未翻转 + WakeUp 后立即再次进入"的边角情况,
// 历史上曾在 dialog/connecting 期间关功放,导致欢迎语无声。
auto& app = Application::GetInstance();
auto state = app.GetDeviceState();
if (state != kDeviceStateIdle) {
ESP_LOGW(TAG, "PowerSaveTimer 在非 idle 状态(%d)触发,忽略关功放", (int)state);
return;
}
ESP_LOGI(TAG, "🔋 进入低功耗模式CPU降频、Light Sleep启用、功放关闭");
auto codec = GetAudioCodec();
if (codec) {

View File

@ -2,6 +2,7 @@
#include "lvgl.h"
#include "esp_lvgl_port.h"
#include "esp_log.h"
#include "esp_timer.h" // 卡顿优化 5: 字幕节流用 esp_timer_get_time
#include <string.h>
// ====================================================================
@ -174,7 +175,7 @@ void ai_chat_screen_init(void) {
// 降低 GIF 定时器频率10ms→20ms平衡动画流畅度与 CPU 占用
lv_gif_t *gifobj = (lv_gif_t *)gif_emotion;
lv_timer_set_period(gifobj->timer, 20);
lv_timer_set_period(gifobj->timer, 33); // 卡顿优化 3: 20ms→33ms 减半 PSRAM 流量
// GIF 图标表情上方居中45x45
// 表情高89顶边y=-44.5icon高45中心再上移几像素避免重叠
@ -373,14 +374,14 @@ void ai_chat_set_emotion(const char* emotion) {
lv_gif_set_src(gif_emotion, entry->emotion_gif);
// set_src 内部会重建 10ms 定时器,重新设置为 50ms 降低 CPU 占用
lv_gif_t *gifobj = (lv_gif_t *)gif_emotion;
lv_timer_set_period(gifobj->timer, 20);
lv_timer_set_period(gifobj->timer, 33); // 卡顿优化 3: 20ms→33ms 减半 PSRAM 流量
gif_animation_paused = false;
// 处理叠加图标
if (entry->icon_gif) {
lv_gif_set_src(gif_icon, entry->icon_gif);
lv_gif_t *icon_gifobj = (lv_gif_t *)gif_icon;
lv_timer_set_period(icon_gifobj->timer, 20);
lv_timer_set_period(icon_gifobj->timer, 33); // 卡顿优化 3: 20ms→33ms
lv_obj_clear_flag(gif_icon, LV_OBJ_FLAG_HIDDEN);
} else {
// 隐藏图标时暂停其定时器,避免空跑浪费 CPU
@ -425,6 +426,17 @@ void ai_chat_set_chat_message(const char* role, const char* content) {
return;
}
// 卡顿优化 5: 100ms 最小更新间隔(防抖)
// AI 流式 TTS 字幕每秒 5-15 次推送,节流后最多每秒 10 次
// 减少 PSRAM 写入流量 5-10 倍chat_label 重绘)
// 例外:空内容(清空字幕)不节流,立即响应
static int64_t last_update_us = 0;
int64_t now_us = esp_timer_get_time();
if (content[0] != '\0' && (now_us - last_update_us) < 100000) { // 100ms
return;
}
last_update_us = now_us;
if (!lvgl_port_lock(500)) { // 200ms → 500msGIF 解码繁忙时给予更长等待)
ESP_LOGW(TAG, "LVGL锁超时跳过字幕更新");
return;

View File

@ -215,7 +215,7 @@ esp_err_t bg_gif_demo_switch_gif(const char *new_gif_path) {
// CLAUDE.md "lv_gif_set_src 会重建定时器" 经验)
lv_gif_t *gifobj = (lv_gif_t *)g_gif_obj;
if (gifobj->timer) {
lv_timer_set_period(gifobj->timer, 20);
lv_timer_set_period(gifobj->timer, 33); // 卡顿优化 3: 20ms→33ms 减半 PSRAM 流量
}
lvgl_port_unlock();

View File

@ -330,7 +330,7 @@ void lvgl_lcd_init(){
.task_stack = 8192,
.task_affinity = -1,
.task_max_sleep_ms = 500,
.timer_period_ms = 5
.timer_period_ms = 16 // 卡顿优化 4: 5ms→16ms (60Hz) 减少 LVGL CPU 占用 60%
};
lvgl_port_init(&lvgl_cfg);