OpenHarmony 小鸿 AI 开发实战 12:Python asyncio 语音服务怎样接住设备
WS63 端完成 WebSocket 握手后,真正的语音工作才到服务端开始:接收 OTA 查询,返回字体与协议配置;为每条 WebSocket 连接创建 Session;收集 Opus;在 VAD stop 后执行 ASR;把文本交给 LLM;再将 TTS 音频分帧送回设备。任何一环的格式、超时或资源边界不一致,设备端都可能表现为“连接成功但没有回答”。
本文对应小鸿 AI 当前真实的 Python 服务端,它负责接住 OpenHarmony WS63 设备发出的协议消息。服务没有使用 FastAPI、Flask 或第三方 WebSocket 框架,而是直接基于 asyncio.start_server() 解析 HTTP 和 WebSocket。这样依赖较少,也意味着握手、frame、路由、限流和异常关闭都由项目代码自己承担。本轮完成的是本地语法检查和确定性冒烟,不是生产服务器在线状态证明。

服务端入口是一套 asyncio TCP 服务器
main() 使用 asyncio.start_server(handle_client, host, port) 监听连接。每个 client 先读取 HTTP header,再由 handle_http() 判断普通 HTTP、WebSocket Upgrade、OTA、字体、健康检查或 chat。处理结束后统一关闭 writer,WebSocket 路径则在同一连接上继续读帧。
async def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument(
"--host", default=env("HOST", "127.0.0.1"))
parser.add_argument(
"--port", type=int,
default=int(env("PORT", "18088")))
args = parser.parse_args()
server = await asyncio.start_server(
handle_client, args.host, args.port)
async with server:
await server.serve_forever()
源码默认端口是 18088,但 systemd unit 的 ExecStart 明确传入 --host 0.0.0.0 --port 8002。因此讨论部署端口时要看实际启动参数,不能只看 Python 默认值。公开文章也不应把某个当前公网 IP 当成架构常量;host、HTTP base URL 和 WS URL都应从环境配置注入。
主前缀是 /hajimi,/xiaohong 只是兼容别名
当前代码把 API_PREFIX 设为 /hajimi,把 /xiaohong 保留为旧设备兼容前缀。OTA、WebSocket、字体、健康检查和 chat 路由都同时接受两者,但新的配置响应应返回主前缀。
API_PREFIX = "/hajimi"
LEGACY_API_PREFIX = "/xiaohong"
if path_only in {
f"{API_PREFIX}/font.bin",
f"{LEGACY_API_PREFIX}/font.bin"
}:
writer.write(
file_response(FONT_FILE_PATH, headers))
兼容别名适合迁移窗口,不等于两套产品命名可以永久混用。日志、监控和文档最好统一记录 canonical prefix,并单独统计 legacy 请求量;当旧固件占比降到可控范围后,再决定是否移除别名。

OTA 响应同时承担固件、字体和 WebSocket 配置
设备请求 OTA 时,服务端先解析 JSON。普通设备请求返回固件版本信息与 WebSocket 配置;字体请求通过 board type 区分,返回 font.bin 的版本、URL、size 和 SHA-256。字体 URL使用 /hajimi/font.bin,正好对应第 08 篇 LittleFS 下载器。
Range 下载能否续传还取决于文件响应实现。当前 file_response() 解析 Range header,计算 start/end,返回完整文件或分段内容。设备端只有在响应码、Content-Range 和本地 offset 一致时才继续写 .part;因此后端不能简单把 Range 忽略后仍返回一份 200 全量数据而不让设备察觉。
OTA 返回的 WebSocket token 属于认证信息。下面只保留环境变量名:
PUBLIC_HTTP_BASE_URL=...
PUBLIC_WS_URL=...
HAJIMI_WS_TOKEN=...
FONT_FILE_PATH=font.bin
FONT_VERSION=...
文章、Git 仓库和测试输出都不应包含真实 token、API key 或云厂商 voice ID。示例默认值只能用于本地开发,部署时应由权限受控的环境文件覆盖。
WebSocket Upgrade 后先建立 Session
服务端校验 Upgrade 请求、计算 Sec-WebSocket-Accept 并返回 101,然后为连接创建 Session。Session 记录 session id、设备对应的 dialogue key、voice key、协议版本、音频格式、采样率、帧时长、累计 binary frame/byte 以及本轮 Opus packet。
@dataclass
class Session:
session_id: str
dialogue_key: str = ""
voice_key: str = DEFAULT_VOICE_KEY
binary_frames: int = 0
binary_bytes: int = 0
audio_packets: list[bytes] = field(
default_factory=list)
protocol_version: int = 1
audio_format: str = "opus"
sample_rate: int = 16000
channels: int = 1
frame_duration_ms: int = 40
audio_overflow: bool = False
设备 hello 到达后,服务端更新这些协议参数并回 server hello。设备端只有收到这个 hello 才把 audio channel 标成 opened。两端分别维护 Session 与 Agent 状态,但用相同的 hello、listen、tts 消息和 binary frame 约定对齐生命周期。
Session 对音频帧数和总字节设硬上限
无限收集 binary frame 会让一个异常客户端耗尽服务器内存。当前代码设置 MAX_AUDIO_FRAMES = 750 和 MAX_AUDIO_BYTES = 512 * 1024。每个 binary packet 进入 Session 时累计 frame 和 byte;超过任一上限后清空 packet 列表并设置 audio_overflow,不再继续堆积。
MAX_AUDIO_FRAMES = 750
MAX_AUDIO_BYTES = 512 * 1024
if (
len(session.audio_packets) >=
MAX_AUDIO_FRAMES
or session.binary_bytes >
MAX_AUDIO_BYTES
):
session.audio_packets.clear()
session.audio_overflow = True
这是一种保护服务器的硬失败,而不是语音质量策略。设备正常发送多少帧取决于 40 ms frame 时长、说话长度和协议开销;如果经常触发上限,应先检查 VAD stop 是否丢失、断线后 Session 是否释放、客户端是否重复发送,而不是直接把上限扩大十倍。

listen/stop 才触发 ASR→LLM→TTS
服务端收到 binary frame 时只收集音频,不会对每个 40 ms packet 单独跑一次识别。设备端 VAD 结束并确认最后一帧已经上行后发送 listen/stop,服务端才把当前 packet 序列交给 ASR。
ASR 返回空文本时不能继续制造一个似是而非的回答;返回有效 question 后,LLM 调用通过 asyncio.to_thread() 从事件循环移到工作线程,因为同步 HTTP 或模型 SDK 可能阻塞。答案经过长度、角色和重复检查,再进入 TTS。
question = await asyncio.to_thread(
recognize_session_audio, session)
if question:
answer = await asyncio.to_thread(
answer_question, question, session)
else:
answer = "没有听清问题,请靠近麦克风重新说一遍。"
session.audio_packets.clear()
await send_tts_response(writer, session, answer)
真实代码中的函数参数和异常分支比示意更完整。关键顺序是 packet 收集、stop、ASR、LLM、TTS;若设备提前 stop、服务器在每帧识别或 TTS 未准备好就发 stop,都会破坏端到端时序。

对话历史按设备隔离,并设置数量与时间边界
服务端根据 Device-Id、Client-Id 或 HTTP 请求中的 identity 生成 dialogue key。历史表和 voice selection 表都由线程锁保护,因为 LLM 调用在工作线程中执行。当前历史最多保留配置的轮数,同时有 TTL 和最大设备数,避免一个长期运行的进程无限积累。
回答生成不只是“把问题发给模型”。当前代码有 persona、答案长度、重复检测、相似度重试、设备历史和句子截断逻辑。response_variety_smoke.py 专门覆盖了重复回答检测、相似回答重试、两条设备历史、persona、response mode 与独立事实审查等分支。
这类测试可以证明确定性规则没有在一次修改中被破坏,但不能证明云模型永远返回高质量内容。线上仍需观察超时、空响应、错误码、敏感内容处理与成本,并为外部模型不可用准备可理解的设备提示。
TTS 用 JSON 控制帧包住二进制 Opus 流
send_tts_response() 先整理文本并选择 voice profile,再生成语音。发送顺序是 TTS start JSON、若干 binary Opus frame、TTS stop JSON。设备收到 start 后从 LISTENING 转 SPEAKING,binary frame 进入播放缓冲,收到 stop 后仍要等待播放队列排空。
server -> {"type":"tts","state":"start",...}
server -> binary Opus frame 1
server -> binary Opus frame 2
server -> ...
server -> {"type":"tts","state":"stop"}
服务端配置了 TTS 生成超时,并对初始 burst frame 做控制。首包太慢会让用户误以为设备没响应;一次塞太多又会加大网络 send buffer 和设备播放队列压力。真实体验需要同时测生成时延、首帧时延、播放连续性和 stop 后的收尾。

健康检查展示能力状态,但不是完整业务验收
/hajimi/health 返回 service、canonical/legacy prefix、LLM 是否启用、模型与 persona 参数、ASR/TTS status 以及可用 voice 列表。它适合判断进程是否响应、配置是否被读取,也能帮助部署脚本做基础探针。
但 health 为 200 不代表设备语音已打通:WebSocket Upgrade 可能被反向代理挡住,token 可能不匹配,字体 Range 可能配置错误,ASR 模型可能只在首次调用时加载失败,TTS 云服务也可能超时。完整验收仍应使用一条真实设备会话覆盖 OTA、hello、Opus 上行、ASR 文本、LLM 答案、TTS 下行和设备播放。
当前部署文件存在两处必须正视的不一致
第一处是端口:Python 默认 18088,systemd unit 显式启动在 8002。只要运维以 unit 为准并让反向代理指向 8002,这不是 bug;但文档和监控必须写清实际入口。
第二处更危险:仓库中的旧 Nginx 片段仍只代理 /xiaohong/,并指向 18088;当前 canonical prefix 是 /hajimi,systemd 又使用 8002。这个片段不能直接当作当前可部署配置,更不能因为文件名存在就声称线上已生效。
# 迁移后的方向示意,不代表已部署事实
location /hajimi/ {
proxy_pass http://127.0.0.1:8002/hajimi/;
}
WebSocket location 还需要 Upgrade、Connection、Host 和超时等 header。真正修改服务器前应先备份现有 Nginx 配置,执行语法检查,reload 后再用 health 与 WebSocket 客户端验证。本篇只审计本地文件,没有连接生产主机,也没有替用户修改线上配置。

本轮测试结果与证据边界
本轮对 server.py、asr_service.py、tts_service.py、protocol_smoke.py 和 response_variety_smoke.py 执行了 Python 语法编译检查;protocol_smoke.py 输出包含 audio_stop_json=3、downlink_opus=4、asr_to_llm=ok、protocol_headers=ok、voice_switch=ok 和 voice_reconnect=ok;回答多样性冒烟的重复检测、相似度重试、设备历史、persona、response mode、事实审查和句子截断均为 ok。
这些结果属于本地测试。它们没有证明生产 systemd 正在运行、Nginx 已使用新前缀、真实 DeepSeek/ASR/TTS 凭据有效,也没有证明 WS63 实机在公网弱网下稳定。文章把 source_snapshot.json 的逐文件 SHA-256 作为代码证据锚点,把本地 smoke 作为测试证据,把尚未执行的线上和实机检查列为待办,避免把三个阶段混成一个“验证通过”。
至此,设备侧的 CI1302 Opus、Agent/WebSocket 状态机和 Python asyncio 服务已经能够按同一协议图阅读。下一步继续扩展功能前,最值得先补的是端到端可观测性:为每个 session 贯通 device id、session id、audio frame count、ASR 文本、LLM 耗时、TTS 首帧和 close reason。只有日志能够跨设备与服务器对齐,偶发的“没听见、没回答、播一半”才会从猜测变成可定位的问题。
更多推荐



所有评论(0)