Baji_Rtc_Toy/main/dzbj/ai_chat_ui.c
Rdzleo 4b7b1949d4 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>
2026-05-14 11:38:48 +08:00

459 lines
17 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#include "ai_chat_ui.h"
#include "lvgl.h"
#include "esp_lvgl_port.h"
#include "esp_log.h"
#include "esp_timer.h" // 卡顿优化 5: 字幕节流用 esp_timer_get_time
#include <string.h>
// ====================================================================
// PoC 开关(验证不同显示方案)
// USE_SPRITE_POC — sprite_test.binRGB565 raw方案已弃用
// USE_DUAL_GIF_POC — 两个 GIF 循环播放(已弃用)
// USE_BG_GIF_POC — 背景图 + 透明 GIF 叠加(方案 C当前启用 ⭐)
// 都注释:恢复原 GIF 眨眼显示
// ====================================================================
// #define USE_SPRITE_POC
// #define USE_DUAL_GIF_POC
#define USE_BG_GIF_POC
#ifdef USE_SPRITE_POC
#include "sprite_demo.h"
#endif
#ifdef USE_DUAL_GIF_POC
#include "dual_gif_demo.h"
#endif
#ifdef USE_BG_GIF_POC
#include "bg_gif_demo.h"
#endif
// lv_gif API 通过 lvgl.h 自动包含LV_USE_GIF=y 时)
static const char *TAG = "AI_CHAT_UI";
// 声明阿里巴巴普惠体 20px 4bppGB2312 简体中文+ASCII
LV_FONT_DECLARE(font_puhui_20_4);
#if LV_USE_GIF
// 需要访问 lv_gif_t 内部结构(暂停/恢复定时器)
#include "extra/libs/gif/lv_gif.h"
// 声明 GIF 表情资源压缩优化200x89
LV_IMG_DECLARE(emotion_angry_200_89);
LV_IMG_DECLARE(emotion_blink1_200_89);
LV_IMG_DECLARE(emotion_blink_fast_200_89);
LV_IMG_DECLARE(emotion_blink_slow_200_89);
LV_IMG_DECLARE(emotion_dizzy_200_89);
LV_IMG_DECLARE(emotion_happy_200_89);
LV_IMG_DECLARE(emotion_sad_200_89);
LV_IMG_DECLARE(emotion_sleep_200_89);
// 声明 GIF 图标资源压缩优化45x45
LV_IMG_DECLARE(icon_emotion_confused_45);
LV_IMG_DECLARE(icon_emotion_sleep_45);
LV_IMG_DECLARE(icon_emotion_thinking_45);
#endif
// 声明旧 PNG 表情资源LV_USE_GIF 未启用时的回退)
LV_IMG_DECLARE(ui_img_neutral_png);
LV_IMG_DECLARE(ui_img_happy_png);
LV_IMG_DECLARE(ui_img_sad_png);
LV_IMG_DECLARE(ui_img_angry_png);
LV_IMG_DECLARE(ui_img_crying_png);
LV_IMG_DECLARE(ui_img_funny_png);
LV_IMG_DECLARE(ui_img_laughing_png);
// 屏幕和控件
static lv_obj_t *ai_screen = NULL;
static lv_obj_t *status_label = NULL;
static lv_obj_t *chat_label = NULL;
static lv_obj_t *chat_container = NULL; // Phase 5: 字幕半透明容器
#if LV_USE_GIF
static lv_obj_t *gif_emotion = NULL; // GIF 表情对象
static lv_obj_t *gif_icon = NULL; // GIF 图标对象(叠加在表情右上角)
static bool gif_animation_paused = false;
#else
static lv_obj_t *emoji_img = NULL; // 旧 PNG 表情
#endif
// 背景色(纯黑,与 GIF 背景一致避免色差)
#define BG_COLOR 0x000000
#ifdef USE_BG_GIF_POC
// Phase 4: 数字人 hiyori GIF 路径映射表22 情绪 → 3 GIF
typedef struct {
const char *emotion;
const char *hiyori_gif_path;
} hiyori_emotion_map_t;
static const hiyori_emotion_map_t hiyori_emotion_map[] = {
// 默认/积极 → m06
{"neutral", "/spiflash/hiyori_m06.gif"},
{"happy", "/spiflash/hiyori_m06.gif"},
{"laughing", "/spiflash/hiyori_m06.gif"},
{"funny", "/spiflash/hiyori_m06.gif"},
{"loving", "/spiflash/hiyori_m06.gif"},
{"relaxed", "/spiflash/hiyori_m06.gif"},
{"delicious", "/spiflash/hiyori_m06.gif"},
{"kissy", "/spiflash/hiyori_m06.gif"},
{"confident", "/spiflash/hiyori_m06.gif"},
{"silly", "/spiflash/hiyori_m06.gif"},
{"blink", "/spiflash/hiyori_m06.gif"},
{"curious", "/spiflash/hiyori_m06.gif"},
// 思考/疲倦 → m07
{"sleepy", "/spiflash/hiyori_m07.gif"},
{"thinking", "/spiflash/hiyori_m07.gif"},
{"confused", "/spiflash/hiyori_m07.gif"},
{"embarrassed", "/spiflash/hiyori_m07.gif"},
{"dizzy", "/spiflash/hiyori_m07.gif"},
// 负面/严肃 → m03
{"sad", "/spiflash/hiyori_m03.gif"},
{"crying", "/spiflash/hiyori_m03.gif"},
{"angry", "/spiflash/hiyori_m03.gif"},
{"surprised", "/spiflash/hiyori_m03.gif"},
{"shocked", "/spiflash/hiyori_m03.gif"},
};
#define HIYORI_EMOTION_MAP_SIZE (sizeof(hiyori_emotion_map) / sizeof(hiyori_emotion_map[0]))
static const char* find_hiyori_gif(const char *emotion) {
for (int i = 0; i < HIYORI_EMOTION_MAP_SIZE; i++) {
if (strcmp(emotion, hiyori_emotion_map[i].emotion) == 0) {
return hiyori_emotion_map[i].hiyori_gif_path;
}
}
return "/spiflash/hiyori_m06.gif"; // 默认/未映射 → m06
}
#endif // USE_BG_GIF_POC
// 表情→GIF 映射表
typedef struct {
const char *name;
const lv_img_dsc_t *emotion_gif;
const lv_img_dsc_t *icon_gif; // NULL 表示无叠加图标
} emotion_gif_entry_t;
#if LV_USE_GIF
static const emotion_gif_entry_t emotion_gif_map[] = {
{"neutral", &emotion_blink_slow_200_89, NULL},
{"happy", &emotion_happy_200_89, NULL},
{"laughing", &emotion_happy_200_89, NULL},
{"funny", &emotion_happy_200_89, NULL},
{"loving", &emotion_happy_200_89, NULL},
{"relaxed", &emotion_happy_200_89, NULL},
{"delicious", &emotion_happy_200_89, NULL},
{"kissy", &emotion_happy_200_89, NULL},
{"confident", &emotion_happy_200_89, NULL},
{"sad", &emotion_sad_200_89, NULL},
{"crying", &emotion_sad_200_89, NULL},
{"angry", &emotion_angry_200_89, NULL},
{"surprised", &emotion_blink_fast_200_89, NULL},
{"shocked", &emotion_blink_fast_200_89, NULL},
{"silly", &emotion_blink_fast_200_89, NULL},
{"embarrassed", &emotion_blink_fast_200_89, &icon_emotion_thinking_45},
{"thinking", &emotion_blink_fast_200_89, &icon_emotion_thinking_45},
{"confused", &emotion_blink_fast_200_89, &icon_emotion_confused_45},
{"curious", &emotion_blink_fast_200_89, &icon_emotion_confused_45},
{"dizzy", &emotion_dizzy_200_89, NULL},
{"blink", &emotion_blink1_200_89, NULL},
{"sleepy", &emotion_sleep_200_89, &icon_emotion_sleep_45},
};
#define EMOTION_GIF_MAP_SIZE (sizeof(emotion_gif_map) / sizeof(emotion_gif_map[0]))
#endif
void ai_chat_screen_init(void) {
// 创建 AI 对话屏幕
ai_screen = lv_obj_create(NULL);
lv_obj_set_style_bg_color(ai_screen, lv_color_hex(BG_COLOR), 0);
lv_obj_set_style_bg_opa(ai_screen, 255, 0);
lv_obj_clear_flag(ai_screen, LV_OBJ_FLAG_SCROLLABLE);
#if LV_USE_GIF
// GIF 表情屏幕正中央200x89
gif_emotion = lv_gif_create(ai_screen);
lv_gif_set_src(gif_emotion, &emotion_blink_slow_200_89);
lv_obj_align(gif_emotion, LV_ALIGN_CENTER, 0, 0);
// 降低 GIF 定时器频率10ms→20ms平衡动画流畅度与 CPU 占用
lv_gif_t *gifobj = (lv_gif_t *)gif_emotion;
lv_timer_set_period(gifobj->timer, 33); // 卡顿优化 3: 20ms→33ms 减半 PSRAM 流量
// GIF 图标表情上方居中45x45
// 表情高89顶边y=-44.5icon高45中心再上移几像素避免重叠
gif_icon = lv_gif_create(ai_screen);
lv_obj_align(gif_icon, LV_ALIGN_CENTER, 0, -70);
lv_obj_add_flag(gif_icon, LV_OBJ_FLAG_HIDDEN);
#else
// 旧 PNG 表情(回退方案)
emoji_img = lv_img_create(ai_screen);
lv_img_set_src(emoji_img, &ui_img_neutral_png);
lv_obj_align(emoji_img, LV_ALIGN_CENTER, 0, 0);
#endif
// 状态文本暂时隐藏GIF 模式下不需要文字)
status_label = lv_label_create(ai_screen);
lv_obj_set_style_text_font(status_label, &font_puhui_20_4, 0);
lv_obj_set_style_text_color(status_label, lv_color_white(), 0);
lv_obj_set_width(status_label, 300);
lv_obj_set_style_text_align(status_label, LV_TEXT_ALIGN_CENTER, 0);
lv_label_set_text(status_label, "");
lv_obj_align(status_label, LV_ALIGN_CENTER, 0, 90);
#if LV_USE_GIF
lv_obj_add_flag(status_label, LV_OBJ_FLAG_HIDDEN);
#endif
// Phase 5: 字幕容器(无背景,仅作位置/尺寸约束,最多 2 行)
chat_container = lv_obj_create(ai_screen);
lv_obj_set_size(chat_container, 320, 56); // 高度 = 2 行 (~24px) + padding
lv_obj_align(chat_container, LV_ALIGN_BOTTOM_MID, 0, -10);
lv_obj_set_style_bg_opa(chat_container, LV_OPA_TRANSP, 0); // 完全透明(无灰底)
lv_obj_set_style_border_width(chat_container, 0, 0);
lv_obj_set_style_pad_all(chat_container, 4, 0);
lv_obj_clear_flag(chat_container, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(chat_container, LV_OBJ_FLAG_HIDDEN); // 初始无字幕时隐藏
// 字幕标签(白色文本,最多 2 行,超出显示 ...
chat_label = lv_label_create(chat_container);
lv_obj_set_style_text_font(chat_label, &font_puhui_20_4, 0);
lv_obj_set_style_text_color(chat_label, lv_color_black(), 0);
lv_obj_set_size(chat_label, 312, 48); // 宽 312容器 320-pad8高 = 2 行
lv_obj_set_style_text_align(chat_label, LV_TEXT_ALIGN_CENTER, 0);
lv_label_set_long_mode(chat_label, LV_LABEL_LONG_DOT); // 超出 2 行显示 ...
lv_label_set_text(chat_label, "");
lv_obj_center(chat_label);
// 加载屏幕
lv_disp_load_scr(ai_screen);
#ifdef USE_SPRITE_POC
// === Sprite Sheet PoC用 RGB565 raw 替代 GIF 显示 ===
// 隐藏原有 GIF 表情对象(避免 LVGL 与 DMA 同时刷新同一区域)
#if LV_USE_GIF
if (gif_emotion) {
lv_obj_add_flag(gif_emotion, LV_OBJ_FLAG_HIDDEN);
}
if (gif_icon) {
lv_obj_add_flag(gif_icon, LV_OBJ_FLAG_HIDDEN);
}
#endif
// 启动 sprite sheet 播放(从 SPIFFS 加载到 PSRAMDMA 直接写 LCD
esp_err_t ret = sprite_demo_start("/spiflash/sprite_test.bin");
if (ret == ESP_OK) {
ESP_LOGI(TAG, "Sprite PoC 启动成功");
} else {
ESP_LOGW(TAG, "Sprite PoC 启动失败: %s回退到 LVGL", esp_err_to_name(ret));
// 失败时显示原 GIF
#if LV_USE_GIF
if (gif_emotion) lv_obj_clear_flag(gif_emotion, LV_OBJ_FLAG_HIDDEN);
#endif
}
#endif // USE_SPRITE_POC
#ifdef USE_DUAL_GIF_POC
// === 双 GIF 循环播放 PoC ===
// 隐藏原有 GIF 表情控件,由 dual_gif_demo 创建新的 lv_gif 控件接管
#if LV_USE_GIF
if (gif_emotion) {
lv_obj_add_flag(gif_emotion, LV_OBJ_FLAG_HIDDEN);
}
if (gif_icon) {
lv_obj_add_flag(gif_icon, LV_OBJ_FLAG_HIDDEN);
}
#endif
// GIF 时长(由 Python 脚本预先测得):
// dance_0_gesture.gif: 58 帧 = 2900 ms
// tapbody_0_greet.gif: 100 帧 = 5000 ms
esp_err_t dgret = dual_gif_demo_start(
"/spiflash/dance_0_gesture.gif", 2900,
"/spiflash/tapbody_0_greet.gif", 5000);
if (dgret == ESP_OK) {
ESP_LOGI(TAG, "Dual GIF PoC 启动成功");
} else {
ESP_LOGW(TAG, "Dual GIF PoC 启动失败: %s", esp_err_to_name(dgret));
#if LV_USE_GIF
if (gif_emotion) lv_obj_clear_flag(gif_emotion, LV_OBJ_FLAG_HIDDEN);
#endif
}
#endif // USE_DUAL_GIF_POC
#ifdef USE_BG_GIF_POC
// === 背景图 + 透明 Hiyori GIF 叠加(方案 C===
// 隐藏原有 GIF 表情控件,由 bg_gif_demo 创建新的图层
#if LV_USE_GIF
if (gif_emotion) {
lv_obj_add_flag(gif_emotion, LV_OBJ_FLAG_HIDDEN);
}
if (gif_icon) {
lv_obj_add_flag(gif_icon, LV_OBJ_FLAG_HIDDEN);
}
#endif
esp_err_t bgret = bg_gif_demo_start(
"/spiflash/Background_360x360.jpg",
"/spiflash/hiyori_m06.gif"); // Phase 3: m06 默认表情neutral/积极240x320 居中
if (bgret == ESP_OK) {
ESP_LOGI(TAG, "BG+GIF PoC 启动成功");
// Phase 5: bg_img/gif_obj 后于 chat_container 创建z-index 会遮挡字幕
// 用 move_foreground 把字幕容器提到最上层
if (chat_container) {
lv_obj_move_foreground(chat_container);
ESP_LOGI(TAG, "字幕容器已提升到最上层");
}
} else {
ESP_LOGW(TAG, "BG+GIF PoC 启动失败: %s", esp_err_to_name(bgret));
#if LV_USE_GIF
if (gif_emotion) lv_obj_clear_flag(gif_emotion, LV_OBJ_FLAG_HIDDEN);
#endif
}
#endif // USE_BG_GIF_POC
}
void ai_chat_set_status(const char* status) {
if (!status_label) return;
#if LV_USE_GIF
// GIF 模式下隐藏状态文字,仅记录日志
ESP_LOGD(TAG, "状态: %sGIF模式不显示", status);
(void)status;
#else
lvgl_port_lock(50);
lv_label_set_text(status_label, status);
lvgl_port_unlock();
#endif
}
void ai_chat_resume_animation(void) {
#if LV_USE_GIF
if (!gif_emotion || !gif_animation_paused) return;
if (!lvgl_port_lock(200)) return;
lv_gif_t *gifobj = (lv_gif_t *)gif_emotion;
lv_timer_resume(gifobj->timer);
gif_animation_paused = false;
ESP_LOGI(TAG, "GIF动画已恢复播放");
lvgl_port_unlock();
#endif
}
void ai_chat_set_emotion(const char* emotion) {
if (!emotion) return;
ESP_LOGI(TAG, "GIF表情切换: %s", emotion);
#ifdef USE_BG_GIF_POC
// PoC 模式bg_gif_demo 在运行时优先切换数字人 hiyori GIF
if (bg_gif_demo_is_running()) {
const char *path = find_hiyori_gif(emotion);
esp_err_t r = bg_gif_demo_switch_gif(path);
if (r != ESP_OK) {
ESP_LOGW(TAG, "hiyori 切换失败: %s, ret=%s", path, esp_err_to_name(r));
}
return; // 不再走 emoji 路径emoji 控件已被隐藏)
}
#endif
#if LV_USE_GIF
if (!gif_emotion) return;
if (!lvgl_port_lock(200)) {
ESP_LOGW(TAG, "LVGL锁超时跳过表情切换: %s", emotion);
return;
}
// 查找映射表
const emotion_gif_entry_t *entry = NULL;
for (int i = 0; i < EMOTION_GIF_MAP_SIZE; i++) {
if (strcmp(emotion, emotion_gif_map[i].name) == 0) {
entry = &emotion_gif_map[i];
break;
}
}
// 未找到映射,默认使用 neutral
if (!entry) {
ESP_LOGW(TAG, "未映射的GIF表情: %s, 使用 neutral", emotion);
entry = &emotion_gif_map[0]; // neutral
}
// 切换表情 GIFlv_gif_set_src 内部已自动启动播放)
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, 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, 33); // 卡顿优化 3: 20ms→33ms
lv_obj_clear_flag(gif_icon, LV_OBJ_FLAG_HIDDEN);
} else {
// 隐藏图标时暂停其定时器,避免空跑浪费 CPU
lv_gif_t *icon_gifobj = (lv_gif_t *)gif_icon;
if (icon_gifobj->gif) {
lv_timer_pause(icon_gifobj->timer);
}
lv_obj_add_flag(gif_icon, LV_OBJ_FLAG_HIDDEN);
}
ESP_LOGI(TAG, "GIF表情切换: %s%s", emotion, entry->icon_gif ? " +图标" : "");
lvgl_port_unlock();
#else
// 旧 PNG 回退逻辑
if (!emoji_img) return;
lvgl_port_lock(50);
const lv_img_dsc_t *img = &ui_img_neutral_png;
if (strcmp(emotion, "neutral") == 0) img = &ui_img_neutral_png;
else if (strcmp(emotion, "happy") == 0) img = &ui_img_happy_png;
else if (strcmp(emotion, "sad") == 0) img = &ui_img_sad_png;
else if (strcmp(emotion, "angry") == 0) img = &ui_img_angry_png;
else if (strcmp(emotion, "surprised") == 0) img = &ui_img_funny_png;
else if (strcmp(emotion, "crying") == 0) img = &ui_img_crying_png;
else if (strcmp(emotion, "laughing") == 0) img = &ui_img_laughing_png;
lv_img_set_src(emoji_img, img);
lvgl_port_unlock();
#endif
}
void ai_chat_set_chat_message(const char* role, const char* content) {
if (!chat_label || !chat_container) return;
if (!content) content = "";
(void)role; // 当前不区分 USER/AI 角色前缀
// 锁外去重:流式 ASR 推送大量重复中间结果,相同内容直接返回
// 减少 LVGL 锁竞争(与 GIF 解码任务竞争)
static char last_content[256] = {0};
if (strncmp(last_content, content, sizeof(last_content)) == 0) {
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;
}
lv_label_set_text(chat_label, content);
// 内容为空时隐藏容器(不留半透明黑框)
if (content[0] == '\0') {
lv_obj_add_flag(chat_container, LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_clear_flag(chat_container, LV_OBJ_FLAG_HIDDEN);
}
lvgl_port_unlock();
// 成功更新后缓存当前内容(用于下次去重)
strncpy(last_content, content, sizeof(last_content) - 1);
last_content[sizeof(last_content) - 1] = '\0';
}