Baji_Rtc_Toy/main/dzbj/ai_chat_ui.c
Rdzleo 497c1b4654 feat(rtc-only): Phase 4 - 情绪标签 → 数字人 hiyori GIF 映射 + 切换接口
按 GSD 框架 .planning/milestones/digital_human_rtc/phases/phase_04_emotion_mapping/
规划完成 Phase 4 情绪映射 + 运行时切换。

## 核心变更

### bg_gif_demo 新增运行时切换接口

```c
esp_err_t bg_gif_demo_switch_gif(const char *new_gif_path);
```

实现要点:
- 先 heap_caps_free(g_gif_data) 释放旧 PSRAM,再加载新 GIF,单峰仅一份
- 内部 static last_gif_path 去重(相同路径直接返回 ESP_OK)
- 切换后立即 lv_timer_set_period(timer, 20) 防止 lv_gif_set_src 重建为默认 10ms
- LVGL 锁保护 200ms 超时

### 22 情绪 → 3 hiyori GIF 映射表

定义在 main/dzbj/ai_chat_ui.c USE_BG_GIF_POC 包裹内:

| GIF | 情绪标签 (个数) |
|-----|----------------|
| m06 (默认/积极) | neutral, happy, laughing, funny, loving, relaxed, delicious, kissy, confident, silly, blink, curious (12) |
| m07 (思考/疲倦) | sleepy, thinking, confused, embarrassed, dizzy (5) |
| m03 (负面/严肃) | sad, crying, angry, surprised, shocked (5) |

22 个标准情绪 100% 覆盖,未映射的情绪默认 fallback 到 m06。

### ai_chat_set_emotion 改造

PoC 模式下优先切换数字人大图(不再切隐藏的 emoji 200x89):

```c
#ifdef USE_BG_GIF_POC
if (bg_gif_demo_is_running()) {
    const char *path = find_hiyori_gif(emotion);
    bg_gif_demo_switch_gif(path);
    return;
}
#endif
// 非 PoC 模式 fallback emoji 路径保留
```

## 调用链(已与现有 RTC 字幕解析对接,无需改 application.cc)

RTC 字幕 → application.cc:1419 display->SetEmotion(emo)
  → AiChatDisplay::SetEmotion (display/ai_chat_display.cc)
  → ai_chat_set_emotion (dzbj/ai_chat_ui.c)
  → bg_gif_demo_switch_gif (PoC 模式)
  → 数字人 hiyori GIF 切换

## 烧录验证(用户实测)

监控 60s 期间捕获情绪切换:
- neutral / happy → m06(已在播,去重)
- thinking → m07 切换成功 (590ms 延迟)
- confused → m07 去重跳过
- AI 回复结束 → 自动回到 neutral
- 无 abort / 重启

m06 ↔ m07 切换屏幕可视确认,m03 走相同代码路径无需重复测试。

## GSD 文档(同时提交)

- .planning/milestones/digital_human_rtc/phases/phase_04_emotion_mapping/PLAN.md
- .planning/milestones/digital_human_rtc/phases/phase_04_emotion_mapping/EMOTION_REPORT.md
2026-05-13 11:59:38 +08:00

407 lines
15 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 <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;
#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, 20);
// 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
// 聊天消息文本(暂时隐藏,不显示字幕)
chat_label = lv_label_create(ai_screen);
lv_obj_set_style_text_font(chat_label, &font_puhui_20_4, 0);
lv_obj_set_style_text_color(chat_label, lv_color_hex(0xAAAAAA), 0);
lv_obj_set_width(chat_label, 300);
lv_obj_set_style_text_align(chat_label, LV_TEXT_ALIGN_LEFT, 0);
lv_label_set_text(chat_label, "");
lv_obj_align(chat_label, LV_ALIGN_CENTER, 0, 50);
lv_label_set_long_mode(chat_label, LV_LABEL_LONG_WRAP);
lv_obj_add_flag(chat_label, LV_OBJ_FLAG_HIDDEN);
// 加载屏幕
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 启动成功");
} 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, 20);
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_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) return;
// 字幕暂时隐藏,不更新内容
// 后续恢复时去掉 return 和 ai_chat_screen_init 中的 LV_OBJ_FLAG_HIDDEN
(void)role;
(void)content;
}