代码改动: - 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>
459 lines
17 KiB
C
459 lines
17 KiB
C
#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.bin(RGB565 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 4bpp(GB2312 简体中文+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.5,icon高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 加载到 PSRAM,DMA 直接写 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, "状态: %s(GIF模式不显示)", 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
|
||
}
|
||
|
||
// 切换表情 GIF(lv_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 → 500ms(GIF 解码繁忙时给予更长等待)
|
||
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';
|
||
}
|