news 2026/7/31 5:48:01

OpenHarmony 小鸿 AI 开发实战 15:从回答文本到 CI1302 扬声器的 TTS 下行链路

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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/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,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 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 命令序列以及扬声器录音。做到这些,才可以把“代码链路存在”升级为“实机扬声器播放已验证”。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/31 5:43:51

C++恒等变换:从零开销实现到高性能编程实战

1. 项目概述&#xff1a;从数学概念到高性能代码恒等变换&#xff0c;听起来是个挺数学的词&#xff0c;但在我们搞C性能优化的老手眼里&#xff0c;它远不止一个简单的“输入等于输出”的数学定义。简单来说&#xff0c;恒等变换就是一个函数或操作&#xff0c;无论给它什么输…

作者头像 李华
网站建设 2026/7/31 5:39:53

SourcePawn开发环境搭建指南:从零配置编译器与本地测试服务器

1. 从零开始的SourcePawn脚本环境搭建如果你正在接触SourceMod插件开发&#xff0c;或者对《反恐精英&#xff1a;全球攻势》、《求生之路2》等Source引擎游戏的服务器定制感兴趣&#xff0c;那么SourcePawn这门脚本语言就是你绕不开的工具。很多新手在第一步“准备环境”上就卡…

作者头像 李华
网站建设 2026/7/31 5:39:47

R语言在气象水文数据分析中的应用与实战技巧

1. 为什么气象水文领域需要R语言&#xff1f;在气象水文这个数据密集型领域&#xff0c;R语言正成为越来越多研究人员的首选工具。我从事水文数据分析工作已有8年&#xff0c;从最初使用Excel手动处理数据&#xff0c;到后来转向MATLAB&#xff0c;最终在2015年完全切换到R语言…

作者头像 李华
网站建设 2026/7/31 5:35:35

GitHub Actions 测试流水线优化:矩阵测试、缓存策略与报告发布实战

1. 项目概述&#xff1a;为什么我们需要一个“聪明”的测试流水线&#xff1f;如果你和我一样&#xff0c;经历过从本地npm test到在 CI/CD 里跑测试的转变&#xff0c;那你一定懂那种痛&#xff1a;每次提交代码&#xff0c;都要等上十几二十分钟&#xff0c;看着流水线一个接…

作者头像 李华
网站建设 2026/7/31 5:31:17

嵌入式系统多芯片协作:从单片机到双片机架构设计实践

这次我们来聊聊一个有趣的技术问题&#xff1a;我们都知道单片机&#xff0c;那有没有"双片机"呢&#xff1f;先说结论&#xff1a;从严格的技术定义来说&#xff0c;并没有"双片机"这个标准术语。单片机&#xff08;Microcontroller Unit, MCU&#xff09…

作者头像 李华
网站建设 2026/7/31 5:30:40

ESP32固件烧录全攻略:从flash_download_tool配置到深度问题排查

1. 从一次失败的固件烧录说起那天下午&#xff0c;我正试图给一块新到的ESP32-C3开发板刷入一个自定义的固件。按照惯例&#xff0c;我打开了乐鑫官方的flash_download_tool&#xff0c;选择了正确的芯片型号&#xff0c;加载了编译好的.bin文件&#xff0c;设置了正确的0x0偏移…

作者头像 李华