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:
parent
497c1b4654
commit
f2be9922b6
@ -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 字幕协议本身限制)
|
||||
@ -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)。
|
||||
@ -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';
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user