OpenHarmony 小鸿 AI 开发实战 15:从回答文本到 CI1302 扬声器的 TTS 下行链路
用户听到扬声器播出一句回答之前,项目里至少经过了五种不同的数据形态:LLM 返回的回答文本、Fish Audio 或离线 VITS 生成的 PCM、服务端编码出的 16 kHz Opus、WebSocket 二进制帧、WS63 解码后的 PCM,以及最终送往 CI1302 的 0x020B 载荷。任何一层把“帧时长”“压缩包长度”或“PCM 字节数”混为一谈,设备都可能只显示文字、不出声,或者播半句后卡住。
本文只讨论小鸿 AI 当前 OpenHarmony mini / LiteOS-M / WS63 路径中的真实 TTS 下行实现。服务端源码来自本地 Python 后端,设备端源码来自当前 AtomGit 工作树;文中的代码块均摘自这些文件。需要先说明的是,设备端相关文件目前有未提交修改,因此 Git 提交号只能说明基线,逐文件 SHA-256 才是本文代码的证据锚点。本轮没有重新构建、烧录或录制扬声器音频,不能把静态审计和本地协议冒烟写成实机播放验收。

完整链路的关键不是“传音频”,而是每一段都说清格式
服务端拿到回答文本后,先执行适合语音播报的文本清理,再选择 TTS 后端。TTS 后端无论返回什么原始采样率,都会被归一化、重采样为 16 kHz 单声道 PCM16,并按 40 ms 对齐;随后用系统 libopus 编码成一组独立的裸 Opus 包。服务端按 tts/start、tts/sentence_start、二进制音频包、tts/stop 的顺序写入同一条 WebSocket。
WS63 的 Mongoose 协议层根据协议版本去掉 v2 或 v3 的二进制头,v1 则直接把整帧看作裸 Opus。Agent 回调把每个 Opus 包解码成 PCM16,交给四槽下行缓冲。CI1302 并不是被服务器主动“推满”,而是先收到开始播放命令,再用 0x020A 请求下一段;WS63 的 AudioPlayTask 每次取出不超过 4096 字节 PCM,封装成 0x020B 回给 CI1302。在正常、未触发播放超时或用户打断的路径上,只有服务端已经发送 stop、缓存也真正排空后,才发送 0x020C 结束播放。
这条链路里有两个不能偷换的概念。第一,WebSocket 上的二进制数据是 Opus,CI1302 0x020B 的载荷是 PCM,两者不相同。第二,40 ms 是每个 Opus 包对应的音频时长,不是包的字节长度;当前设备协议结构中的 frame_duration 字段在收包路径上实际承载 payload 字节数,这是历史命名债,阅读代码时必须结合赋值位置判断。
Fish 是部署选择,离线 VITS 是代码默认与故障回退
tts_service.py 的模块默认值是 offline,不是 Fish。当前部署脚本会把 TTS_PROVIDER 设为 fish,同时把 TTS_FALLBACK_OFFLINE 设为 1。因此准确说法是:代码支持 Fish 与 sherpa-onnx VITS 两条路径;部署配置选择 Fish 为主路径,Fish 抛出项目定义的 TTS 异常时才回退到离线 VITS。若只运行源码而不加载部署环境,主路径仍然是离线模型。
TTS_PROVIDER = env("TTS_PROVIDER", "offline").strip().lower()
TTS_FALLBACK_OFFLINE = env("TTS_FALLBACK_OFFLINE", "1").lower() not in {
"0",
"false",
"no",
"off",
}
真实的选择逻辑如下。Fish 请求成功时返回 16 kHz PCM16;Fish 失败且允许回退时,才调用本地 MandarinTTS。如果配置了未知 provider,代码会明确抛出不可用异常,而不是静默选择某个后端。
def _generate_source_audio(prepared: str, reference_id: str = ""):
if TTS_PROVIDER == "fish":
try:
return (
_fish_pcm_samples(
_FISH_TTS.generate_pcm16(prepared, reference_id=reference_id)
),
TTS_SAMPLE_RATE,
"fish",
)
except TTSError:
if not TTS_FALLBACK_OFFLINE:
raise
generated = _TTS.generate(prepared)
return generated.samples, int(generated.sample_rate), "offline-fallback"
if TTS_PROVIDER == "offline":
generated = _TTS.generate(prepared)
return generated.samples, int(generated.sample_rate), "offline"
raise TTSUnavailableError(f"unsupported TTS provider: {TTS_PROVIDER}")
Fish 路径需要 API key、模型名和音色 reference id;这些都属于服务端环境配置,不应写进固件或公开文章。本文只记录变量名和控制逻辑,不复述真实主机、密钥、音色标识或代理入口。离线 VITS 也不是“无条件可用”:模型、词典、token 文件和 sherpa-onnx 缺一不可,health 中的 model_ready 才能反映静态资源是否齐全。

两种 TTS 来源必须先收敛成同一份设备 PCM
云端和离线模型的输出幅度、采样率与句首句尾形态可能不同。项目没有把原始结果直接编码,而是先转成浮点数组,去直流分量,以 99.5 百分位的绝对幅度作为参考做增益,再对少量峰值软限制。之后若原始采样率不是 16 kHz,就用插值重采样;句首与句尾加入淡入淡出,额外补 160 ms 前导静音与 80 ms 尾部静音。
最后一步按 40 ms 对齐。16 kHz × 40 ms 等于 640 个单声道样本,每个 PCM16 样本 2 字节,因此一帧解码后通常是 1280 字节。若总样本数不能被 640 整除,服务端先补零,再转为小端有符号 16 位字节流。这个对齐不是美化细节,而是 OpusPacketEncoder.encode_pcm16() 的输入约束;长度不整齐会直接抛出编码错误。
TTS_SAMPLE_RATE = 16000
TTS_FRAME_DURATION_MS = 40
TTS_LEAD_SILENCE_MS = 160
TTS_TAIL_SILENCE_MS = 80
TTS_MAX_TEXT_CHARS = 72
回答文本在合成前还会替换不适合中文朗读的技术词,清理 Markdown 符号、URL 和孤立英文标识,并限制到 72 个字符。这与屏幕展示长度相近,但两者属于不同层:compact_answer_text() 负责设备回答文本,speech_text() 负责发音可读性。不能把文本裁剪当成音频分帧,更不能按 UTF-8 字节数推导音频时长。
服务端输出的是 16 kHz、单声道、40 ms 裸 Opus 包
完成 PCM 规范化后,服务端通过 libopus 建立单声道编码器。synthesize() 的返回对象同时保存 packets、采样率、帧时长、总音频时长、源采样率和实际 backend,这些字段既用于下发,也用于日志确认到底走了 Fish、offline 还是 offline-fallback。
def synthesize(text: str, reference_id: str = "") -> SynthesizedSpeech:
prepared = speech_text(text)
if not prepared:
raise TTSError("TTS text is empty")
samples, source_sample_rate, backend = _generate_source_audio(
prepared, reference_id=reference_id
)
pcm16 = _pcm16_for_device(samples, source_sample_rate)
with OpusPacketEncoder(sample_rate=TTS_SAMPLE_RATE, channels=1) as encoder:
packets = encoder.encode_pcm16(pcm16, TTS_FRAME_DURATION_MS)
if not packets:
raise TTSEncodeError("Opus encoder returned no packets")
return SynthesizedSpeech(
packets=packets,
sample_rate=TTS_SAMPLE_RATE,
frame_duration_ms=TTS_FRAME_DURATION_MS,
duration_ms=len(packets) * TTS_FRAME_DURATION_MS,
source_sample_rate=source_sample_rate,
backend=backend,
)
“裸 Opus 包”意味着每个 WebSocket binary payload 本身不是 Ogg 文件,也没有 WAV 头。协议 v1 直接发送包;v2 在前面加 16 字节头,v3 加 4 字节头。服务端和设备都根据协商出来的 protocol version 做同样的封装与拆包。如果用播放器直接打开某一帧,得不到一段正常音频,并不能说明编码失败。

控制帧和音频帧的顺序决定设备什么时候开始播
send_tts_response() 先在工作线程中执行合成,并设置超时。无论合成成功还是失败,当前实现都会发送 tts/start、带回答文本的 tts/sentence_start,最后发送 tts/stop;只有合成成功时中间才有 binary Opus。这样屏幕仍可显示文本,但也意味着设备端必须能处理“有 start/stop、没有音频”的回答,不能无限等待 PCM。
当前源码的 TTS_INITIAL_BURST_FRAMES 默认是 10,部署脚本也写入 10,允许范围是 4 到 12。前 10 帧立即发送,此后按每帧 40 ms 节流。仓库 README 仍写“前四帧”,与执行代码不一致;本文以 server.py 和部署脚本为准,并把 README 视为待同步文档,不能为了沿用旧文字把当前实现写成四帧。
def wrap_downlink_audio(session: Session, packet: bytes, timestamp_ms: int) -> bytes:
if session.protocol_version == 2:
return struct.pack("!HHIII", 2, 0, 0, timestamp_ms, len(packet)) + packet
if session.protocol_version == 3:
return struct.pack("!BBH", 0, 0, len(packet)) + packet
return packet
10 个 40 ms 包对应约 400 ms 音频。设备每包解码后通常得到 1280 字节 PCM,10 包约 12800 字节,能形成三个完整 4096 字节块并留下尾段,既满足启动缓冲,又没有一开始就超过 OpenHarmony 路径的四槽队列。后续 40 ms 节流是服务端发送节奏,不等于 CI1302 每 40 ms 固定请求一次;CI1302 仍按自己的 0x020A 拉取节奏消费 PCM。
WS63 协议层先拆 WebSocket 头,再把 Opus 交给 Agent
Mongoose 收到二进制帧后,根据 version 解析 payload size。v2 从 16 字节头中读取长度与时间戳,v3 从 4 字节头读取长度;头长度或 payload size 不一致时,当前代码会退回“整帧按裸 Opus”处理,避免直接静默丢包。v1 从一开始就是裸 Opus。
这里有一个容易造成误读的结构设计:protocol_audio_packet_t.frame_duration 在发送和接收代码里被用作 payload_size。Agent 随后把它作为 opus_len 传给解码器。字段名没有反映真实语义,但赋值和调用链是自洽的。后续重构更合理的做法是增加 payload_size,把真正的 40 ms 时长保留为独立字段;在现状下,文章不能声称该字段的值恒为 40。
static void on_audio_data(void *user_data, protocol_audio_packet_t *packet)
{
(void)user_data;
if (packet == NULL || packet->payload == NULL || packet->frame_duration == 0U) {
return;
}
#if CI1302_TTS_DECODE_OPUS_TO_PCM
const int sr = CI1302_020B_PCM_SAMPLE_RATE_HZ;
int n = ci1302_opus_decode_pcm(sr, 1, packet->payload, (int)packet->frame_duration,
s_tts_opus_pcm_scratch, AGENT_TTS_OPUS_PCM_MAX_SAMPLES);
if (n <= 0) {
log_error("%s on_audio_data: opus->pcm decode failed (%d)\r\n", TAG, n);
return;
}

Opus 在 WS63 解码,CI1302 收到的始终是 PCM
设备端编译宏 CI1302_TTS_DECODE_OPUS_TO_PCM 当前为 1,BUILD.gn 收录了解码器、下行队列和播放任务,并引用 WS63 SDK 的 Opus include。解码器没有调用可能从小堆分配内存的 opus_decoder_create(),而是准备 20 KiB、16 字节对齐的静态存储,先用 opus_decoder_get_size(1) 检查容量,再调用 opus_decoder_init()。
#define CI1302_OPUS_DEC_STORAGE_BYTES (20 * 1024)
static unsigned char s_dec_storage[CI1302_OPUS_DEC_STORAGE_BYTES] CI1302_OPUS_DEC_ALIGN;
static OpusDecoder *s_dec;
static int s_sr;
static int s_ch;
单帧输出 scratch 是 1920 个 int16_t,覆盖 Opus 单声道最大 120 ms 帧。当前服务端实际发 40 ms,因此正常解码返回 640 个样本,也就是 1280 PCM 字节。解码失败时本帧被丢弃并记录错误,不会把压缩字节冒充 PCM 入队。调用 ci1302_tts_reset() 时也会重置解码器状态,避免新回答沿用上一段的内部状态。
这说明“CI1302 支持 Opus 下行”并不是当前工程事实。当前事实是 WS63 链接并运行 Opus 解码,CI1302 继续消费 PCM 协议。如果以后 CI1302 固件增加原生 Opus 命令,必须同时改命令号、载荷契约和播放任务,不能只删掉解码函数。
四槽 PCM 队列负责把网络节奏转换成 CI1302 拉流节奏
在 SUPPORT_OHOS 路径中,TTS_SLOT_NUM 是 4,每槽最多 4096 字节。解码得到的 1280 字节 PCM 会先进入 4096 字节合并缓冲;凑满一槽后才成为 ready block。这样收到第一帧时不会立刻通知 CI1302 开播,而是等至少一个完整块,降低 CI1302 第一次发 0x020A 时无数据可回的概率。
#define TTS_CHUNK_MAX CI1302_TTS_CHUNK_MAX_BYTES
#if SUPPORT_OHOS
#define TTS_SLOT_NUM 4
#else
#define TTS_SLOT_NUM 8
#endif
队列满时当前策略是丢最旧块,并对最初三次以及之后每 32 次溢出打印告警。这是一种保证系统继续运行的实时策略,不是无损策略。若出现溢出,应先核对服务端 burst、节流、WebSocket 重发、CI1302 请求节奏与 AudioPlayTask 是否被阻塞,而不是只把四槽改成更大的静态数组。LiteOS-M 的 BSS、任务栈和 Wi-Fi 内存相互竞争,盲目扩容可能把音频卡顿变成任务创建失败。
不足 4096 字节的尾段也不能永远留在合并缓冲。收到 tts/stop 后,Agent 调用 ci1302_tts_flush_pending() 把尾段推入槽;如果 CI1302 恰好已发来真实的 0x020A,ci1302_tts_take_next_tx_block() 也允许直接取当前不足一槽的数据。协议支持可变长度 0x020B,因此尾段没有必要伪造到 4096 字节。
0x0201、0x020A、0x020B、0x020C 组成 CI1302 拉式播放
当至少一个完整 PCM 块可用时,Agent 向音频消息队列投递 eAud_StartPlay,AudioPlayTask 把它转换为 0x0201。CI1302 随后发 0x020A 请求数据,UART 解析器将其转换为 eAud_SendAudioData;播放任务取出下一段 PCM,用 16 字节头加 payload 的方式发送 0x020B。单次载荷最大 4096 字节,数据长度写在帧头第 8、9 字节。
case eAud_StartPlay:
log_debug("[Aud_Play] MCU ==>> CI1302 ==>> : eAud_StartPlay\r\n");
process_send_cmd(0x0201, NULL, 0);
break;
case eAud_SendAudioData: {
bool all_drained = false;
if (ci1302_tts_take_next_tx_block(&ci1302_audio_block, &ci1302_audio_block_len,
CI1302_UART_PAYLOAD_MAX, &all_drained)) {
process_send_cmd_payload(0x020B, ci1302_audio_block, ci1302_audio_block_len);
}
process_send_cmd_payload() 会先循环写完整 16 字节帧头,再循环写 payload;UART 单次写返回 0 时最多重试 8 次,每次间隔 500 微秒。这里仍有一个可观测性缺口:payload 循环最终没有像帧头那样明确检查并记录 pay_off != len,因此出现持续 UART 写失败时,日志可能不足以直接证明一帧是否完整送达。文章只能说明当前重试逻辑存在,不能把它写成“UART 可靠送达保证”。
CI1302 的 0x020D 也可能在整段 TTS 尚未结束时上报。当前解析器只要发现 PCM 队列不空,就再次投递与 0x020A 相同的数据事件,避免内部一段播放结束后停止拉取剩余语音。这一分支是长回答不只播前半句的重要补偿。

tts/stop 不是立刻 0x020C,必须等待最后一块真正排空
流式网络会在两个 Opus 包之间出现短暂空窗。如果每次 all_drained 都立即发结束命令,设备可能在下一包到来前就执行 0x020C,表现为只播半句。当前实现把“服务器已经 stop”与“本地队列已经空”拆成两个条件:收到 stop 后设置 s_pending_endplay_after_drain,AudioPlayTask 只有在后续一次取块确认 all_drained 时才消费这个标志并落入 eAud_EndPlay。
结束分支先等待 300 ms,再发 0x020C 和 0x0204,最后清空队列与 Opus decoder。WebSocket 断开、播放超时或用户打断也有单独的 abort 路径,会取消 pending 标志并投递 EndPlay。这里的核心不在某个延时值,而在所有异常路径都必须最终复位 s_tts_pending_start_play、s_tts_play_started、缓存、decoder 和 Agent 状态,否则下一轮回答会继承上一轮残留。
纯文本无音频是另一条必须覆盖的路径。服务端合成异常时仍发送 start/sentence_start/stop,设备端看到 s_tts_had_audio == false 后不会等待 PCM 排空,而是保留回答页若干时间后回到待机。若把 stop 简化成统一 EndPlay,就可能在从未 StartPlay 的情况下向 CI1302 发结束命令。
排查“有文字没声音”要按数据形态逐段定位
服务端第一组证据是 TTS 日志:backend、packets、audio duration 和 elapsed。若 synth=failed,先看 Fish 配置、回退模型、超时和 libopus;若 packets 大于零,再确认 WebSocket binary 数量和协议头。当前本地 protocol_smoke.py 用四个伪 Opus 包验证了 JSON 顺序、下行 binary 帧、音色切换和重连保持,但伪包没有经过真实 Opus 解码,不能代替声学测试。
设备端第二组证据是协议层是否收到 binary、解析后的 payload 字节数、opus_decode 返回样本数以及 tts push 是否成功。第三组是 PCM 队列:是否形成 ready block、是否溢出丢旧块、stop 后尾段是否 flush。第四组才是 CI1302 UART:0x0201 是否发出、是否收到 0x020A、每次 0x020B 长度是否为偶数且不超过 4096、最后是否出现 0x020C。
如果服务端日志显示 40 ms 包持续发送,但设备第一帧就 decode error,优先检查 v2/v3 头是否被正确剥离,不要先怀疑扬声器。若解码正常且 PCM 入队,却没有 0x020A,检查开始播放事件和 CI1302 状态。若有多次 0x020B 仍无声,才继续核对 PCM 小端、采样率、音量、静音设置和 CI1302 固件。按层定位比反复扩大缓冲更快,也更不容易掩盖协议错误。

本轮验证结果与仍未闭环的部分
本轮对 server.py、tts_service.py 与 deploy_remote.py 做了 Python AST 解析,三个文件均通过;重新运行 protocol_smoke.py,结果包含 audio_stop_json=3、downlink_opus=4、asr_to_llm=ok、protocol_headers=ok、voice_switch=ok 和 voice_reconnect=ok;response_variety_smoke.py 的重复检测、相似回答重试、设备历史、角色、回答模式、独立事实审查和句子截断也通过。
这些测试证明当前 Python 协议编排的确定性分支仍可运行,但没有调用真实 Fish Audio,也没有加载并试听离线 VITS,更没有验证伪 Opus 能被 WS63 解码。设备端源码审计确认了 16 kHz 解码、20 KiB 静态 decoder、四槽 4096 字节 PCM 缓冲以及 0x020B 拉式播放链路;由于当前工作树含未提交修改,本轮没有把它们描述为某个已发布固件的运行事实。
真正的闭环仍需在同一轮构建和烧录后,用真实回答至少覆盖 Fish 成功、Fish 失败转离线、短句尾块、长句持续拉流、网络中断、用户打断和静音/音量变化,并同时保存服务端 packet 日志、WS63 解码与队列日志、CI1302 命令序列以及扬声器录音。做到这些,才可以把“代码链路存在”升级为“实机扬声器播放已验证”。
更多推荐


所有评论(0)