OpenHarmony 小鸿 AI 开发实战 17:从长文本到发热量化的端到端验收
一套 OpenHarmony 语音终端能完成一次问答,不等于它已经通过端到端验收。真正的验收要把问题拆成可追溯的观测点:回答文本是否完整进入设备,240×240 屏幕是否按 UTF-8 边界翻页,最后一页是否停留,TTS 音频是否排空,队列满时怎样处理,服务器从 VAD 结束到首个音频帧花了多久,连续工作时温度和电流又怎样变化。少一层证据,都可能把“偶然跑通”误写成“稳定通过”。
本文依据小鸿 AI 当前真实的 WS63 设备端修改、Python 后端和本地确定性 smoke test 整理。当前设备端仍是 OpenHarmony mini、LiteOS-M 和 WS63 的原生 C/GN/HB 工程,不是 ArkTS 应用。先把结论说清楚:源码与本地协议规则可以复核;两个本地 smoke 已重新通过;本轮没有重新构建、烧录和运行实机;也没有真实温升、电流、CPU 或无线占空比数据。因此这是一套“当前实现审计 + 验证与验收方法 + 未闭环清单”,不是发热已经解决的性能报告。

端到端验收先分开五层证据
第一层是源码:函数、常量、状态机和逐文件 SHA-256 能证明“当前代码写了什么”。第二层是构建:完整命令、退出码、产物时间、大小和哈希能证明“这些源码生成了哪个包”。第三层是烧录:BurnTool 选中的确切包、七镜像写入日志和时间能证明“哪个包进入了设备”。第四层是运行:启动串口、屏幕、按键、Wi-Fi、语音上行、回答显示和扬声器播放能证明“设备行为是什么”。第五层才是量化:时延分段、丢帧、温升、电流、CPU 和无线占空比能回答“是否稳定、瓶颈在哪里”。
这五层不能互相代替。代码中有分页函数,不代表指定固件已经包含它;磁盘上有 .fwpkg,不代表 BurnTool 本次选中了它;出现 All images burn successfully,不代表长回答翻到了最后一页;实机摸起来温热,也不能给出温升或功耗结论。本文后续每个“通过”都会标明证据所在层级。
当前长文本边界不是旧版的 144 字节
当前 lvgl_ui_layout.h 中,待显示回答数组容量为 512 字节,保留结尾 NUL 后最多承载 511 字节;单页临时缓冲上限为 256 字节,翻页周期为 3200 ms。入口当前使用 strncpy(..., 511) 后强制补 NUL,超长外部文本会被截断,而且截断点本身没有做 UTF-8 回退,理论上可能切在多字节字符中间。这里的 256 则是后续分页测量和复制时的 UTF-8 字节上限,不是“每页固定显示 256 个汉字”。一个常见中文字符通常占三个 UTF-8 字节,实际页长还要受字体、字宽、行距、文本框宽高和混合标点影响。
服务端又有另一层边界:DEVICE_ANSWER_CHARS 默认 72,环境变量可在 40~120 之间设置。Python 的这一项按字符裁剪,设备端两个常量按字节管理,不能把它们直接当成同一单位。当前正常服务端路径即使按 120 个四字节 Unicode 码点估算也不超过 480 字节,通常不会撞到 511 字节入口上限;但其他调用方、未来配置变化和异常输入仍需单独测试。
/* 对话框缓存的对话文字大小。AI 回答是动态文本,保留更长的 UTF-8 缓冲供滚动显示。 */
#define DISP_LVGL_TEXT_PENDING_MAX (512)
/* 单页临时缓冲上限;实际页长由当前字体、宽度和高度动态测量。 */
#define DISP_SHOW_TEXT_MAX_LEN (256)
/* 每页显示 3.2 秒;最后一页同样完整停留一周期。 */
#define DISP_SHOW_TEXT_DELAY_MS (3200)

分页由当前字体和文本框实测决定
进入 disp_page_fit_bytes() 的有效字符串不再按固定字节数直接截页。utf8_char_bytes() 先识别 1~4 字节 UTF-8 序列,分页函数保存合法字符边界,再用 lv_text_get_size() 测量候选字符串。函数用二分查找寻找不超过文本框高度的最大字符边界,最后返回字节数,因此分页阶段不会从中间切开三字节中文字符,也能随字体和回答区尺寸变化重新计算。这个保证不覆盖前面 511 字节入口截断;两层边界必须分别测试。
while (low <= high) {
uint16_t mid = (uint16_t)(low + (high - low) / 2U);
size_t bytes = boundaries[mid];
memcpy(disp_chat_text, text, bytes);
disp_chat_text[bytes] = '\0';
lv_point_t measured = {0};
lv_text_get_size(&measured, disp_chat_text, font,
letter_space, line_space, max_width,
LV_TEXT_FLAG_NONE);
if (measured.y <= max_height) {
best = mid;
low = (uint16_t)(mid + 1U);
} else {
if (mid == 0U) {
break;
}
high = (uint16_t)(mid - 1U);
}
}
return (size_t)boundaries[best];
源码层能确认算法和边界,但还不能确认所有字形在实机上都没有裁切。真正的长文本用例应至少包含纯中文、中英混排、数字和标点、换行、罕见字缺字回退、四字节 emoji、接近 511 字节有效载荷的回答,以及刻意超过入口容量的异常输入;每组都要记录入口是否截断、页数、每页首尾字符、是否出现半个字符和最后一页是否可读。
旧快照漂移本身就是一次验收案例
早期文章和路线记录曾引用 144/108 字节分页,41 号包也以分页文本为主题。但当前头文件已经明确改为 DISP_SHOW_TEXT_MAX_LEN=256,实现也升级为按真实字形测量。继续拿旧文章中的 144 当当前常量,会让测试数据、配图和代码块同时失真。
这说明文章验收不能只在第一次写完时检查一次。源文件发生变化后,应重新比对 SHA-256,定位哪些摘录漂移,再决定是修正文稿、重建固件还是保留历史结论并标注版本。41 号包大小为 1,947,048 字节,SHA-256 为 508259C32BF9DBA5081F5F9A0D20676059632153C107033FB8B9E5177C598820;它只能证明一份历史包存在,不能证明当前 256 字节和动态测量代码已烧入设备。
最后一页结束还要与音频排空协同
多页回答的第一页立即显示,后续页由 LVGL timer 每 3200 ms 切换。显示最后一页时,代码先设置 s_show_text_final_dwell=true,等下一个周期再结束滚动。在正常且未触发 watchdog 的路径上,Agent 先等音频队列为空,再检查 disp_text_is_playing();分页仍在进行时不会进入正常返回首页的 hold。与此同时,源码设置了 5 秒音频排空超时和 12 秒总完成超时,命中任一超时会中止本地播放并回到 IDLE,而不是无限等待文字或音频。
bool audio_empty = ci1302_tts_is_empty();
uint32_t stop_elapsed_ms =
(s_tts_stop_tick == 0U) ? 0U : elapsed_ms_since_tick(s_tts_stop_tick);
bool drain_timeout = !audio_empty && stop_elapsed_ms >= AGENT_TTS_DRAIN_TIMEOUT_MS;
bool finish_timeout = stop_elapsed_ms >= AGENT_TTS_FINISH_TIMEOUT_MS;
if (drain_timeout || finish_timeout) {
log_error("%s force TTS finish: audio_empty=%d elapsed=%lu ms\r\n",
TAG, (int)audio_empty, (unsigned long)stop_elapsed_ms);
abort_local_tts_playback(drain_timeout ?
"drain timeout" : "finish timeout");
if (g_protocol != NULL && protocol_has_close_audio(g_protocol)) {
g_protocol->ops->close_audio_channel(g_protocol->impl);
}
set_state(AGENT_STATE_IDLE);
return;
}
if (!audio_empty) {
s_tts_empty_start_tick = 0U;
return;
}
/* 音频结束不代表长文本已经翻完;等分页和最后一页停留结束再回首页。 */
if (disp_text_is_playing()) {
s_tts_empty_start_tick = 0U;
return;
}
if (s_tts_empty_start_tick == 0U) {
s_tts_empty_start_tick = osKernelGetTickCount();
return;
}
uint32_t hold_ms = s_tts_had_audio ? AGENT_TTS_AUDIO_HOLD_MS : AGENT_TTS_TEXT_HOLD_MS;
if (elapsed_ms_since_tick(s_tts_empty_start_tick) < hold_ms) {
return;
}
需要注意一个真实分支:如果回答经测量只有一页,disp_show_pending_text_utf8() 会立即把 s_disp_text_playing 设为 false,不进入多页的最终停留 timer;此后页面保持时间由 TTS 排空后的 hold 逻辑承担。测试报告应把单页、多页和纯文本无音频三种路径分开,而不是只观察一次多页回答。

背压要从四槽 PCM 队列开始观察
服务端发来 Opus 后,WS63 解码为 16 kHz 单声道 PCM,再合并为最多 4096 字节的块。在 OpenHarmony 编译条件下,队列有四个槽。CI1302 用 0x020A 拉取,设备再以 0x020B 返回当前 PCM 段。这个设计避免每个小 Opus 包都立即形成一次 UART 大事务,但四槽并不是无限缓存。
队列满时,当前策略是丢弃最旧块、增加 s_overflow_count,然后写入新块;前三次以及每 32 次打印警告。ci1302_tts_push() 因为执行了“丢旧保新”,未必向上层返回失败,所以仅统计 push 失败无法发现所有音频缺口。实机验收必须同时抓取 [DownLink] tts queue full、下行包数、CI1302 拉取节奏和听感。
if (g_count >= TTS_SLOT_NUM) {
s_overflow_count++;
if (s_overflow_count <= 3U ||
(s_overflow_count % 32U) == 0U) {
log_warn("[DownLink] tts queue full, drop oldest block (count=%lu)\r\n",
(unsigned long)s_overflow_count);
}
g_rp = (uint8_t)((g_rp + 1U) % TTS_SLOT_NUM);
g_count--;
}

服务端节流只能降低风险,不能代替测量
当前 Python 服务端把 TTS 生成超时默认设为 24 秒,并限制在 8~45 秒。下行的前 10 个 40 ms frame 立即发送,形成名义 400 ms 初始缓冲;之后按照每帧 40 ms 的时间线节流。这能减少“一次把整段音频灌满设备队列”的概率,但网络调度、Opus 包长度、设备解码耗时和 CI1302 拉取速度仍会造成抖动。
stream_started = loop.time()
for index, packet in enumerate(speech.packets):
if index >= TTS_INITIAL_BURST_FRAMES:
target = stream_started + (
(index - TTS_INITIAL_BURST_FRAMES + 1)
* speech.frame_duration_ms / 1000.0
)
delay = target - loop.time()
if delay > 0:
await asyncio.sleep(delay)
服务端已有 TTS packets、音频时长和总 elapsed 日志,语音上行也记录 frame、byte、推算时长和 ASR 结果;但还没有一条统一记录贯通“VAD stop、ASR 完成、LLM 完成、TTS 首帧、设备首声、播放结束”。所以本文不能给出端到端 P50、P95 或最坏时延,只能指出需要补齐的时间戳。
本地 smoke 证明的是协议规则没有断
本轮用禁止写入 bytecode 的方式重新执行 protocol_smoke.py 和 response_variety_smoke.py。前者通过 FakeWriter 收集 WebSocket frame,并用替换后的 ASR、LLM、TTS 函数检查 hello、listen、协议 v1/v2/v3 包头、音色切换、重连、三条 TTS JSON 和四个下行 Opus 包。后者检查重复回答、相似度重试、设备历史、persona、模式分类、独立事实复核与完整句截断。
audio_stop_json=3 downlink_opus=4
asr_to_llm=ok protocol_headers=ok
voice_switch=ok voice_reconnect=ok
repeat_detected=ok similarity_retry=ok device_history=2
persona=ok response_modes=ok
independent_fact_review=ok sentence_cut=ok
syntax_compile=ok files=5
这些输出是真实的本地测试结果,但测试中的音频包、识别文本和 TTS 结果是确定性替身。它没有连接公网 WebSocket,没有调用真实 DeepSeek、sherpa-onnx 或 Fish Audio,没有经过 WS63 无线栈、Opus 解码器和 CI1302 UART。因此状态应写成“本地协议回归通过”,不能写成“端到端实机通过”。
端到端时延需要同一会话的观测点
建议每条实机会话使用同一个 session id,至少记录七个时刻:设备发送最后一个 Opus 包、发送 listen/stop、服务端 ASR 完成、LLM 完成、TTS 生成完成、服务端发送首个 binary frame、设备开始发 0x020B 或扬声器出现首声。播放结束还要记录服务端 tts/stop、设备队列排空和 UI 回到首页。

报告不能只写一个“响应 2 秒”。至少要拆出上行收尾、ASR、LLM、TTS 生成、首帧网络、设备启动缓冲和整段排空;同一组固定问题重复多次,分别计算中位数、P95 和失败数。弱网、TTS fallback、短回答、多页回答与连续打断要单独成组。本轮没有执行这些实测,所以没有填写任何毫秒结果。
候选固件必须绑定当前源码才能进入实机结论
当前磁盘上较新的候选包是 000_BURN_THIS_48_THINKING_STATE_RECOVERY_WS63_20260728_2156.fwpkg,大小 1,980,072 字节,SHA-256 为 C7427E87D927EE157400FFEED46E5209B3C7850BDA078E8851619858BA71FA6D;快捷副本的大小和哈希一致。该文件时间晚于本文抽取的设备源码修改时间,但文件时间的先后关系仍不能替代构建日志和源文件清单。
candidate:
000_BURN_THIS_48_THINKING_STATE_RECOVERY_WS63_20260728_2156.fwpkg
bytes:
1980072
sha256:
C7427E87D927EE157400FFEED46E5209B3C7850BDA078E8851619858BA71FA6D
设备端基础提交是 cf18843f621269dba6fbdec2fe87f1b3b13460dc,而本文涉及的 display、agent 和 TTS downlink 文件当前均是未提交修改。要把 48 号包写成“当前源码构建结果”,仍需保存本次完整 HB/GN/SDK 命令、退出码、构建时间、业务源文件哈希与输出包哈希。本轮没有重新执行构建,因此构建层只确认“候选文件存在且哈希已读回”。
发热量化要先固定工况和测点
目前没有可引用的真实温升或电流数据。下一轮测量应使用同一块设备、同一固件哈希、正常且固定的供电方式、固定屏幕亮度和音量,并记录环境温度。至少区分启动后空闲、Wi-Fi 已连接但无会话、持续录音上行、等待 ASR/LLM、TTS 播放、屏幕持续翻页六种工况。板温测点要固定在可复现位置;红外测温还要记录表面材质和发射率限制,不能把芯片外壳、PCB 背面和外壳表面混为一个数字。
建议的记录表包含:固件 SHA-256、供电电压、采样时间、环境温度、板上测点温度、电流平均值、电流峰值、屏幕亮度、音量、Wi-Fi RSSI、会话次数、TTS 播放占比、队列 overflow 次数和异常日志。预热时长与采样周期应在测量前固定,例如每种工况先稳定一段固定时间、再按固定周期采样;“例如”是测试方案,不是本文已经执行的数据。

没有这些数据时,只能提出假设:背光、功放、Wi-Fi 活跃、CPU 空转或队列忙等都可能影响热量。触摸感觉、单次瞬时温度或不同环境下两张红外图不足以定位原因。正确顺序是先建立可重复基线,再一次只改变一个因素,最后用温升、电流和日志同时解释差异。
当前验收结论按证据层级落表
截至本文快照,源码层已确认:待显数组容量是 512 字节、有效载荷最多 511 字节,当前单页临时上限是 256 字节而不是旧 144,页长由 LVGL 字形测量决定,多页最后一页有 3.2 秒停留;正常路径会等待分页和音频排空,5 秒/12 秒 watchdog 则提供异常出口。OpenHarmony 下行使用四个 4096 字节 PCM 槽且满时丢最旧块,服务端默认把设备回答裁到 72 字符并以 10 帧初始 burst 加 40 ms 节流发送。
本地测试层已确认:两个确定性 smoke 和五个 Python 文件的内存语法编译通过。构建层仅确认候选包文件、大小和 SHA-256 存在,没有在本轮把它与当前源文件哈希重新绑定。烧录层、启动层、真实长文本翻页、真实播放、端到端时延和温度电流量化均未在本轮执行。
因此发布这篇文章时,最重要的结论不是“所有问题已经解决”,而是得到一条不会混淆证据的闭环路线:先冻结或提交当前设备源码;全量构建并保存日志与包哈希;BurnTool 只选择该哈希;用固定长回答逐页核对字符、页码、最后一页和返回首页;同步抓取协议与 UART 日志;最后在固定工况下测量温度和电流。完成这些步骤后,才能把 ready_local 的文章证据升级为真正的“端到端实机验收通过”。
更多推荐


所有评论(0)