用户听到扬声器播出一句回答之前,项目里至少经过了五种不同的数据形态: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,CI13020x020B的载荷是 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 packet10 个 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 命令序列以及扬声器录音。做到这些,才可以把“代码链路存在”升级为“实机扬声器播放已验证”。