用户听到扬声器播出一句回答之前,项目里至少经过了五种不同的数据形态: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/starttts/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 恰好已发来真实的 0x020Aci1302_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,再发 0x020C0x0204,最后清空队列与 Opus decoder。WebSocket 断开、播放超时或用户打断也有单独的 abort 路径,会取消 pending 标志并投递 EndPlay。这里的核心不在某个延时值,而在所有异常路径都必须最终复位 s_tts_pending_start_plays_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.pytts_service.pydeploy_remote.py 做了 Python AST 解析,三个文件均通过;重新运行 protocol_smoke.py,结果包含 audio_stop_json=3downlink_opus=4asr_to_llm=okprotocol_headers=okvoice_switch=okvoice_reconnect=okresponse_variety_smoke.py 的重复检测、相似回答重试、设备历史、角色、回答模式、独立事实审查和句子截断也通过。

这些测试证明当前 Python 协议编排的确定性分支仍可运行,但没有调用真实 Fish Audio,也没有加载并试听离线 VITS,更没有验证伪 Opus 能被 WS63 解码。设备端源码审计确认了 16 kHz 解码、20 KiB 静态 decoder、四槽 4096 字节 PCM 缓冲以及 0x020B 拉式播放链路;由于当前工作树含未提交修改,本轮没有把它们描述为某个已发布固件的运行事实。

真正的闭环仍需在同一轮构建和烧录后,用真实回答至少覆盖 Fish 成功、Fish 失败转离线、短句尾块、长句持续拉流、网络中断、用户打断和静音/音量变化,并同时保存服务端 packet 日志、WS63 解码与队列日志、CI1302 命令序列以及扬声器录音。做到这些,才可以把“代码链路存在”升级为“实机扬声器播放已验证”。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐