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(含锁优化对比 + 布局规划 + 用户决策记录)
This commit is contained in:
Rdzleo 2026-05-13 13:35:41 +08:00
parent 497c1b4654
commit f2be9922b6
3 changed files with 511 additions and 13 deletions

View File

@ -0,0 +1,263 @@
# Phase 5 PLAN — RTC 字幕显示恢复
> 里程碑: `digital_human_rtc`
> 阶段目标: 恢复 RTC 字幕显示在屏幕底部(半透明黑底白字),不遮挡数字人 hiyori GIF。
## 0. 调研结论
### 0.1 现状
`main/dzbj/ai_chat_ui.c`
- L211: `lv_obj_add_flag(chat_label, LV_OBJ_FLAG_HIDDEN);` —— 字幕被隐藏
- L400-406: `ai_chat_set_chat_message()` 函数体只有 `(void)role; (void)content;`,不更新内容
- L191-199: `status_label` 也被隐藏屏幕中央PoC 模式不需要)
### 0.2 现有调用链(不动)
```
RTC 字幕 (application.cc:1446)
Schedule lambda → display->SetChatMessage(role, msg)
→ AiChatDisplay::SetChatMessage (display/ai_chat_display.cc)
→ ai_chat_set_chat_message(role, content) ← 现在是空函数
```
### 0.3 z-index 隐患(重要)
LVGL 控件按创建顺序排列 z-order后创建在上层。当前 `ai_chat_screen_init()` 调用顺序:
```
1. ai_screen 创建
2. gif_emotion / gif_icon / status_label / chat_label ← 字幕在这步创建
3. lv_disp_load_scr(ai_screen)
4. bg_gif_demo_start()
├─ g_bg_img (lv_img_create on lv_scr_act()) ← 背景图后创建
└─ g_gif_obj (lv_gif_create on lv_scr_act()) ← 数字人 GIF 后创建
```
**问题**: bg_img 和 gif_obj 在 chat_label **之后**创建 → 默认 z-index 更高 → **遮挡字幕**
**修复**: bg_gif_demo_start 之后调用 `lv_obj_move_foreground(chat_label)` 把字幕提到最上层。
## 1. 设计方案
### 1.1 字幕容器结构
```
ai_screen (lv_scr)
├── gif_emotion / gif_icon / status_label (隐藏)
├── chat_container (新增 lv_obj半透明背景)
│ └── chat_label (lv_label文本)
├── bg_img (来自 bg_gif_demo背景图z=4)
├── gif_obj (来自 bg_gif_demo数字人 GIFz=5)
└── (bg_gif_demo_start 后调用 lv_obj_move_foreground(chat_container) 提到 z=最高)
```
### 1.2 字幕容器样式
| 属性 | 值 |
|------|-----|
| 父对象 | `ai_screen` |
| 大小 | 宽 320px × 自适应高度 |
| 位置 | `LV_ALIGN_BOTTOM_MID, 0, -10`(距底部 10px |
| 背景色 | `0x000000` 黑色 |
| 背景透明度 | `LV_OPA_60`60% 不透明 = 40% 透明) |
| 圆角 | `12px` |
| Padding | 上下 8px左右 12px |
| Border | 无 |
### 1.3 字幕标签样式
| 属性 | 值 |
|------|-----|
| 父对象 | `chat_container` |
| 字体 | `font_puhui_20_4`已有GB2312 简体中文) |
| 颜色 | `0xFFFFFF` 白色 |
| 宽度 | 296px容器宽 320 - padding 12*2 - 边距 12*2 |
| 对齐 | `LV_TEXT_ALIGN_CENTER` |
| 长文本模式 | `LV_LABEL_LONG_WRAP`(自动换行) |
| 默认文本 | 空字符串 |
### 1.4 内容更新策略
`ai_chat_set_chat_message(role, content)`
- `role``"USER"``"AI"`(来自 application.cc:1438
- `content` 不为空时显示,为空时清空
- **简化**: 不显示角色前缀,仅显示内容(与 PoC 阶段一致)
- LVGL 锁保护lvgl_port_lock 200ms 超时)
- 用 `lv_label_set_text` 设置文本LVGL 自带换行)
## 2. 任务清单
### Task 5.1: 重构 chat_label + 实现 set_chat_message
**文件**: `main/dzbj/ai_chat_ui.c`
**修改 1**: `ai_chat_screen_init()` 中创建字幕容器(替换 L203-211 chat_label 旧代码)
```c
// === 字幕显示(屏幕底部半透明容器,最上层)===
// 容器:半透明黑底,圆角,避让数字人 GIFGIF 在垂直中部)
chat_container = lv_obj_create(ai_screen);
lv_obj_set_size(chat_container, 320, LV_SIZE_CONTENT); // 自适应高度
lv_obj_align(chat_container, LV_ALIGN_BOTTOM_MID, 0, -10);
lv_obj_set_style_bg_color(chat_container, lv_color_hex(0x000000), 0);
lv_obj_set_style_bg_opa(chat_container, LV_OPA_60, 0); // 60% 不透明
lv_obj_set_style_radius(chat_container, 12, 0);
lv_obj_set_style_border_width(chat_container, 0, 0);
lv_obj_set_style_pad_ver(chat_container, 8, 0);
lv_obj_set_style_pad_hor(chat_container, 12, 0);
lv_obj_clear_flag(chat_container, LV_OBJ_FLAG_SCROLLABLE);
// 字幕标签
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_white(), 0);
lv_obj_set_width(chat_label, 296);
lv_obj_set_style_text_align(chat_label, LV_TEXT_ALIGN_CENTER, 0);
lv_label_set_long_mode(chat_label, LV_LABEL_LONG_WRAP);
lv_label_set_text(chat_label, "");
lv_obj_center(chat_label); // 在容器内居中
```
**修改 2**: 新增静态全局变量 `chat_container`(顶部已有 `chat_label` 静态变量)
**修改 3**: 重构 `ai_chat_set_chat_message()`(替换 L400-406
```c
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
if (!lvgl_port_lock(200)) {
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();
}
```
**验证**:
- 编译通过
- 无未声明变量 / 类型错误
**commit 消息**: `feat(subtitle): 字幕容器重构 - 屏幕底部半透明黑底白字 + 实现 set_chat_message`
---
### Task 5.2: bg_gif_demo_start 后提升字幕到最上层
**文件**: `main/dzbj/ai_chat_ui.c`
修改位置: L232 `bg_gif_demo_start(...)` 调用之后
```c
esp_err_t bgret = bg_gif_demo_start(
"/spiflash/Background_360x360.jpg",
"/spiflash/hiyori_m06.gif");
if (bgret == ESP_OK) {
ESP_LOGI(TAG, "BG+GIF PoC 启动成功");
// Phase 5: 把字幕容器提升到最上层(避免被 bg_img/gif_obj 遮挡)
if (chat_container) {
lv_obj_move_foreground(chat_container);
ESP_LOGI(TAG, "字幕容器已提升到最上层");
}
} else {
...
}
```
**验证**:
- 编译通过
- 烧录后日志显示 "字幕容器已提升到最上层"
**commit 消息**: `fix(subtitle): bg_gif_demo_start 后用 move_foreground 提升字幕层级`
---
### Task 5.3: 烧录验证字幕显示
**步骤**:
1. 编译 + 烧录
2. 启动 monitor等设备进入对话状态
3. 和 AI 说话(一句任意内容),观察:
- 屏幕底部应出现**半透明黑色字幕条**
- 字幕实时显示 AI 的回复文本
- 字幕**不遮挡数字人 hiyori** 上半身
- 长文本自动换行(≤ 3 行可见)
- AI 回复结束后字幕保留(直到下一句话)
4. 日志验证:
```
I (xxxxx) AI_CHAT_UI: 字幕容器已提升到最上层
I (xxxxx) AI_CHAT_UI: GIF表情切换: happy
...AI 回复文本通过 SetChatMessage 更新到字幕...
```
**验证标准**:
- ✅ 字幕实时显示在屏幕底部
- ✅ 字幕半透明背景(可看到后面背景图)
- ✅ 字幕不遮挡数字人 GIF数字人在垂直中部字幕在底部 -10
- ✅ 长文本合理换行
**用户协作**: 烧录后和 AI 对话,目视确认字幕显示效果。
**不产出 commit**(仅验证步骤)
---
### Task 5.4: 生成 SUBTITLE_REPORT.md
**内容**:
- 容器/标签样式参数
- z-index 修复方法move_foreground
- 实测字幕显示日志
- 已知问题(如有)
**commit 消息**: `docs(phase05): 字幕显示恢复验证报告SUBTITLE_REPORT.md`
## 3. 任务顺序
```
Task 5.1 (重构 chat_label) → Task 5.2 (z-index 修复) → Task 5.3 (烧录验证) → Task 5.4 (报告)
```
## 4. 风险与回滚
| 风险 | 缓解 |
|------|------|
| 字幕容器创建失败LVGL 内存不足) | 锁内创建 + 检查返回值,失败时降级 |
| chat_label 没有声明 `chat_container` 静态变量 | Task 5.1 在文件顶部一起声明 |
| move_foreground 在 g_running=false 时调用失败 | Task 5.2 在 bgret==ESP_OK 分支内调用 |
| 字幕和数字人 GIF 视觉重叠 | 容器 align BOTTOM, y=-10数字人 GIF 209×360 居中top=20bottom=380—屏幕 360字幕在 y=290~350 范围;数字人脚底在 y=380 > 360 已被裁切,不冲突 |
| 长文本超容器范围被截断 | `LV_SIZE_CONTENT` 自适应高度 + WRAP 模式自动换行 |
**回滚**: 单独 revert Task 5.1 或 5.2 commit。
## 5. Phase 5 完成验收清单
- [ ] Task 5.1 commit 完成
- [ ] Task 5.2 commit 完成
- [ ] Task 5.3 烧录验证:字幕显示 + 不遮挡 + 实时更新(用户目视)
- [ ] Task 5.4 SUBTITLE_REPORT.md commit
- [ ] 合并 Phase 5 commits 推送 gitea + GitHub
## 6. Phase 5 不做的事
- ❌ Phase 6 的 RTC 空闲超时联动
- ❌ 修改 application.cc 的字幕路由(已正常工作)
- ❌ 修改 status_label暂时仍隐藏PoC 不用)
- ❌ 字幕滚动动画(仅静态换行显示)
- ❌ 用户/AI 区分(不显示角色前缀,简化)
- ❌ 长文本自动滚动(≤ 3 行截断,更长内容由 RTC 字幕协议本身限制)

View File

@ -0,0 +1,195 @@
# SUBTITLE_REPORT — Phase 5 字幕显示恢复验证报告
> 阶段: `phase_05_subtitle_restore`
> 日期: 2026-05-13
> 状态: ✅ **完成**
## 1. 最终字幕样式
| 属性 | 值 |
|------|-----|
| 容器 | `chat_container` (lv_obj, 父=ai_screen) |
| 容器尺寸 | 320 × 56 px |
| 容器位置 | `LV_ALIGN_BOTTOM_MID, 0, -10`(距底 10px |
| 容器背景 | **完全透明** (`LV_OPA_TRANSP`),无灰底 |
| 容器边框 | 无 |
| 容器 padding | 4px |
| 字幕标签 | `chat_label` (lv_label, 父=chat_container) |
| 字幕字体 | `font_puhui_20_4` (GB2312 简体中文20px) |
| 字幕颜色 | **黑色** (`lv_color_black()`) |
| 字幕尺寸 | 312 × 48 px (= 2 行高度) |
| 字幕对齐 | `LV_TEXT_ALIGN_CENTER` |
| 长文本模式 | `LV_LABEL_LONG_DOT` (超 2 行显示 ...) |
## 2. 设计决策记录
### 2.1 颜色white → black用户反馈
| 阶段 | 颜色 | 反馈 |
|------|------|------|
| 初版 | 灰色 0xAAAAAAPoC 前默认)| — |
| 重构 | 白色 `lv_color_white()` | 用户:浅色背景不清晰 |
| **最终** | 黑色 `lv_color_black()` | 数字人浅色衣服/背景上更显眼 |
### 2.2 背景:半透明黑底 → 完全透明
初版用 `LV_OPA_60` 半透明黑色 + 圆角 12px用户反馈"灰底不好看"。
最终改为 `LV_OPA_TRANSP` 完全透明,字幕直接叠加在数字人/背景图上。
### 2.3 行数:自由换行 → 最多 2 行
`LV_LABEL_LONG_WRAP` 模式下长字幕(如 54 字符)会显示 4 行,遮挡数字人。
改为 `LV_LABEL_LONG_DOT` + 高度 48px (= 2 行),超出自动 `...` 截断。
## 3. LVGL 锁优化(重要)
### 3.1 问题
`ai_chat_set_chat_message()``lvgl_port_lock(200)`,与 GIF 解码任务竞争 LVGL 锁。
实测 60s 对话期间出现 **14 次锁超时**
```
W (77729) AI_CHAT_UI: LVGL锁超时跳过字幕更新
W (82089) AI_CHAT_UI: LVGL锁超时跳过字幕更新
... (共 14 次)
```
### 3.2 根因
流式 ASR 推送大量重复中间结果81 次推送 / 30 秒):
- "今天" → "今天天气" → "今天天气怎么样" → "今天天气怎么样?"
- 每次推送都尝试锁 LVGL与 GIF 帧刷新冲突
### 3.3 优化方案
```c
void ai_chat_set_chat_message(const char* role, const char* content) {
if (!chat_label || !chat_container) return;
if (!content) content = "";
(void)role;
// 锁外去重:相同内容直接 return
static char last_content[256] = {0};
if (strncmp(last_content, content, sizeof(last_content)) == 0) {
return;
}
if (!lvgl_port_lock(500)) { // 200 → 500ms
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';
}
```
### 3.4 优化效果
| 指标 | 优化前 | 优化后 |
|------|--------|--------|
| LVGL 锁超时60s 对话) | **14 次** | **0 次** ✅ |
| AI 字幕完整推送 | 部分跳过 | 全部成功 |
## 4. z-index 修复
bg_gif_demo_start() 在 chat_container 之后创建 g_bg_img / g_gif_obj
LVGL 按创建顺序排列 z-order → 字幕被遮挡。
**修复**: bg_gif_demo_start() 返回 ESP_OK 后立即调用:
```c
lv_obj_move_foreground(chat_container);
```
启动日志确认:
```
I (1559) AI_CHAT_UI: BG+GIF PoC 启动成功
I (1559) AI_CHAT_UI: 字幕容器已提升到最上层
```
## 5. 实测验证60s 对话)
```log
[20s] 表情: neutral
[23s] 表情: happy
[28s] 用户: 今天的天气怎么样?
[30s] 字幕情绪: thinking → thinking
[30s] 表情: thinking
[37s] 📝 AI: 今天的天气需要查询实时信息呢,我这就帮你看看~正在上网查询,请稍等一下哦。
[37s] 表情: neutral
[48s] 📝 AI: ### 今日广州天气2026.5.13 周三当前13:30气温31℃西南风2级湿度67%AQI优。
[55s] 📝 AI: 14点起有雷阵雨全天气温29~31℃不宜洗车和户外运动。
```
| 验证项 | 结果 |
|--------|------|
| AI 字幕最终推送 | ✅ 3 次(含 54 字符长字幕) |
| 用户语音转写 | ✅ 18 次 |
| 表情切换 | ✅ neutral/happy/thinking/neutral |
| LVGL 锁超时 | ✅ 0 次 |
| 字幕显示位置 | ✅ 屏幕底部 -10px |
| 不遮挡数字人 | ✅ 数字人 209×360 居中,字幕在 y=296~352 |
| 长字幕 2 行截断 | ✅ 54 字符自动 ... |
| 无 abort / 重启 | ✅ 整段对话稳定 |
## 6. 调用链(已与 application.cc 现有逻辑对接,无需改协议层)
```
RTC 字幕 (application.cc:1446)
Schedule lambda → display->SetChatMessage(role, msg)
→ AiChatDisplay::SetChatMessage (display/ai_chat_display.cc)
→ ai_chat_set_chat_message(role, content) ← Phase 5 实现
├─ 锁外去重last_content
├─ lvgl_port_lock(500)
├─ lv_label_set_text(chat_label, content)
├─ 空内容隐藏容器,非空显示
└─ 缓存 last_content
```
## 7. 布局规划(无视觉冲突)
```
LCD 360×360
y=0 ─┬─ 数字人 GIF 顶部
│ (hiyori 209×360居中显示)
y=296 ──── 字幕容器顶部 (320×56)
y=320 ──── 字幕标签顶部 (312×48)
y=344 ──── 字幕标签底部 (2 行字)
y=350 ──── 字幕容器底部
y=360 ── LCD 底部
```
数字人脚部超出 LCD 360 已被裁切。字幕区域 y=296~350 落在数字人膝盖以下原图被裁切的位置,**理论上无视觉冲突**。
## 8. 风险事项
| 风险 | 实际发生 | 处置 |
|------|---------|------|
| LVGL 锁超时频繁 | ✅ 14 次 | 锁外去重 + 500ms 超时,优化为 0 次 |
| 字幕被 bg_img/gif_obj 遮挡 | ✅ z-index 问题 | move_foreground 修复 |
| 长字幕 4 行遮挡数字人 | ✅ 高度问题 | 限高 48px + LV_LABEL_LONG_DOT 截断 |
| 白色文字在浅色背景上不清 | ✅ 用户反馈 | 改为黑色 |
| 半透明灰底视觉效果差 | ✅ 用户反馈 | 改完全透明 |
## 9. Phase 5 验收结论
- ✅ Task 5.1: chat_container 重构 + ai_chat_set_chat_message 实现commit `a473d7a`
- ✅ Task 5.2: bg_gif_demo_start 后 move_foreground同 commit
- ✅ Task 5.3: 烧录验证字幕显示(用户实测)
- ✅ Task 5.3.1 优化: 锁外去重 + 500ms 超时 + 透明背景 + 2 行限制 + 黑字commit `685e716`
- ✅ Task 5.4: 本报告生成
- ✅ LVGL 锁超时优化 14 次 → 0 次
**下一步**: Phase 6 — RTC 空闲超时联动熄屏(移除独立 sleep_mgr

View File

@ -65,6 +65,7 @@ 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 表情对象
@ -199,16 +200,25 @@ void ai_chat_screen_init(void) {
lv_obj_add_flag(status_label, LV_OBJ_FLAG_HIDDEN);
#endif
// 聊天消息文本(暂时隐藏,不显示字幕)
chat_label = lv_label_create(ai_screen);
// 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_hex(0xAAAAAA), 0);
lv_obj_set_width(chat_label, 300);
lv_obj_set_style_text_align(chat_label, LV_TEXT_ALIGN_LEFT, 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_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_obj_center(chat_label);
// 加载屏幕
lv_disp_load_scr(ai_screen);
@ -280,6 +290,12 @@ void ai_chat_screen_init(void) {
"/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
@ -398,9 +414,33 @@ void ai_chat_set_emotion(const char* emotion) {
}
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;
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';
}