diff --git a/.planning/milestones/digital_human_rtc/phases/phase_05_subtitle_restore/PLAN.md b/.planning/milestones/digital_human_rtc/phases/phase_05_subtitle_restore/PLAN.md new file mode 100644 index 0000000..211ad34 --- /dev/null +++ b/.planning/milestones/digital_human_rtc/phases/phase_05_subtitle_restore/PLAN.md @@ -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,数字人 GIF,z=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 +// === 字幕显示(屏幕底部半透明容器,最上层)=== +// 容器:半透明黑底,圆角,避让数字人 GIF(GIF 在垂直中部) +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=20,bottom=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 字幕协议本身限制) diff --git a/.planning/milestones/digital_human_rtc/phases/phase_05_subtitle_restore/SUBTITLE_REPORT.md b/.planning/milestones/digital_human_rtc/phases/phase_05_subtitle_restore/SUBTITLE_REPORT.md new file mode 100644 index 0000000..af339b6 --- /dev/null +++ b/.planning/milestones/digital_human_rtc/phases/phase_05_subtitle_restore/SUBTITLE_REPORT.md @@ -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(用户反馈) + +| 阶段 | 颜色 | 反馈 | +|------|------|------| +| 初版 | 灰色 0xAAAAAA(PoC 前默认)| — | +| 重构 | 白色 `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)。 diff --git a/main/dzbj/ai_chat_ui.c b/main/dzbj/ai_chat_ui.c index d8ef133..864bffb 100644 --- a/main/dzbj/ai_chat_ui.c +++ b/main/dzbj/ai_chat_ui.c @@ -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 → 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'; }