Baji_Rtc_Toy/main/dzbj/ai_chat_ui.c
Rdzleo f2be9922b6 feat(rtc-only): Phase 5 - RTC 字幕显示恢复(透明背景 + 2 行黑字 + 锁优化)
按 GSD 框架 .planning/milestones/digital_human_rtc/phases/phase_05_subtitle_restore/
规划完成 Phase 5 字幕显示恢复。

## 核心变更(main/dzbj/ai_chat_ui.c)

### 1. chat_container 重构

- 新增 static chat_container 变量(lv_obj 父容器)
- 尺寸 320×56(= 2 行字 + padding 4px*2)
- 位置 LV_ALIGN_BOTTOM_MID 距底 10px
- 完全透明背景(LV_OPA_TRANSP),无灰底
- 初始 HIDDEN,有内容时显示

### 2. chat_label 改造

- 黑色文本(用户反馈白字在浅色背景上不清晰)
- 尺寸 312×48 限制最多 2 行
- LV_LABEL_LONG_WRAP → LV_LABEL_LONG_DOT,超出 2 行自动 ... 截断
- font_puhui_20_4 中文字体不变

### 3. ai_chat_set_chat_message() 实现

原为空函数(PoC 期间 return),本 Phase 完整实现:
- 锁外去重:static last_content[256],相同内容直接返回
- lvgl_port_lock 200ms → 500ms(GIF 解码繁忙时给予更长等待)
- 内容空时隐藏容器,非空显示
- 成功更新后缓存 last_content

### 4. z-index 修复

bg_gif_demo_start() 后立即 lv_obj_move_foreground(chat_container)
否则 bg_img/gif_obj 后于 chat_container 创建会遮挡字幕

## 实测验证(用户协作)

60s 对话期间:
-  AI 字幕完整推送 3 次(含 54 字符长字幕)
-  LVGL 锁超时 14 次 → 0 次(锁外去重生效)
-  表情切换 + 字幕同步工作
-  长字幕自动 2 行截断
-  无 abort/重启

## 调用链(已对接 application.cc 现有逻辑,无需改协议层)

RTC 字幕 → display->SetChatMessage(role, msg)
  → AiChatDisplay::SetChatMessage
  → ai_chat_set_chat_message() ← 本 Phase 实现

## GSD 文档

- PLAN.md
- SUBTITLE_REPORT.md(含锁优化对比 + 布局规划 + 用户决策记录)
2026-05-13 13:35:41 +08:00

447 lines
16 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;
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, 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
// 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, 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 || !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;
}
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';
}