环境:Quectel Pi M2(瑞芯微 RK3576,4×A72@2.2GHz + 4×A53@2.0GHz,6 TOPS NPU,3.8 GB RAM 无 swap)/ Debian 13 (trixie) aarch64,内核 6.1.118-rt36
目标:把「完全离线中文语音助手」的语音播报延迟压到能自然对话的程度
关键词:sherpa-onnx、VITS、Kokoro、Matcha、Vocos、RKNN、RK3576、RTF、首声延迟、NPU 声码器
前言
这是 Quectel Pi M2 离线语音助手系列实践的一篇。前面的链路已经打通:
- 第一篇:板载麦克风录音 + 蓝牙音箱播放
- 第二篇:中文语音唤醒(KWS)
- 第三篇:本地大模型 RKLLM 在 NPU 上跑通(0.5B 26~29 tok/s)
本篇是第四篇:离线 TTS 低延迟优化。
说明:ASR(Paraformer)+ 意图工具层 + 整机闭环 那篇还没发,放到后面单独写。本篇只借用它的时延数据做整机预算,不展开讲那部分的实现。
链路是:
唤醒(KWS) → VAD 录音 → ASR(Paraformer) → LLM(RKLLM/NPU) → TTS → 播放
唤醒、ASR、LLM 都很快,唯独 TTS 一开口就露馅:用户说完话,板子要沉默 8~11 秒才出声,一句 10 秒的回答要合成 17 秒。
这篇文章记录了我把这块硬骨头啃下来的全过程:四条技术路线、每一版的实测数字、以及最后落地的方案。先说结论,方便你判断要不要往下读:
| VITS aishell3 int8(整句合成) | CPU | 1.7 ~ 1.9 | 8 ~ 11 s | 基线,不可用 |
| VITS + 切句流水线 | CPU | 1.55 ~ 1.75 | 2.5 ~ 3.5 s | 能用,但块间有静音空档 |
| Kokoro int8 | CPU | 3.6 ~ 4.0 | 更慢 | 排除 |
| 原厂 RKNN TTS / MMS-TTS | NPU | — | — | flow 算子无法有效加速,排除 |
| Matcha + Vocos(RKNN) | NPU | 0.099 ~ 0.104 | 0.37 ~ 0.43 s | 最终采用 |
最终效果:整机链路 ASR 0.18 s → LLM 0.60 s → TTS 首声 0.41 s,RTF 0.14 ~ 0.18,合成速度约为实时播放的 6~7 倍,首声比原来的 VITS 快了 20 倍以上。
一、先把指标定义清楚
聊 TTS 性能如果只盯「一句话合成要几秒」,很容易被带偏。本文统一用三个指标:
| 首声延迟 | 文本就绪 → 第一个音频帧进入播放器的耗时 | 用户体感「卡不卡」的唯一指标 |
| RTF | 合成耗时 ÷ 音频时长 | < 1 才能跟上实时播放;> 1 一定积压 |
| 块间空档 | 分段合成时,上一段播完到下一段出声的间隔 | 决定「听起来像不像机器人喘气」 |
关键认知:VITS / Matcha 这类非流式声学模型,必须把一整段文本合成完才能出声。所以对整句合成来说:
首声延迟 ≈ 整段音频时长 × RTF
RTF = 1.7 意味着「说 10 秒的话,要先算 17 秒」。这就是 8~11 秒沉默的数学解释 —— 不是哪一行代码慢,是整段合成 + 算力不足叠加的结果。
二、基线:sherpa-onnx VITS aishell3(CPU)
最初用的就是 sherpa-onnx 官方现成方案:vits-zh-aishell3.int8.onnx,CPU 推理。
import sherpa_onnx
cfg = sherpa_onnx.OfflineTtsConfig(
model=sherpa_onnx.OfflineTtsModelConfig(
vits=sherpa_onnx.OfflineTtsVitsModelConfig(
model="vits-aishell3.int8.onnx",
lexicon="lexicon.txt",
tokens="tokens.txt",
noise_scale=0.667, noise_scale_w=0.8, length_scale=1.0,
),
provider="cpu", num_threads=8,
),
rule_fsts="date.fst,number.fst,phone.fst,new_heteronym.fst",
max_num_sentences=1,
)
tts = sherpa_onnx.OfflineTts(cfg)
实测(RK3576,8 线程,22050 Hz 输出):
| 短句(约 10 字) | 2.5 s | 4.2 ~ 4.8 s | 1.7 ~ 1.9 | 4 ~ 5 s |
| 两三句回答(约 40 字) | 9 ~ 11 s | 16 ~ 20 s | 1.7 ~ 1.9 | 8 ~ 11 s |
我先把能调的参数都调了一遍:
- num_threads:4 / 6 / 8 线程,RTF 从 1.9 只降到 1.7,8 线程以后不再有收益(A72 只有 4 个,超线程调度反而互相抢);
- length_scale:只是改变语速,不改变 RTF;
- 锁频:CPU governor 本来就是 performance,没有余量可挖。
结论:CPU 侧已经到顶,瓶颈是模型结构和算力,调参救不了。
三、四条路线逐个实测
3.1 路线 A:Kokoro int8 —— 名气大,但在 RK3576 上更慢
Kokoro 在 x86/手机端口碑很好,sherpa-onnx 1.10.46 也已支持,于是下载了 int8 版本(约 140 MB)实测:
| VITS aishell3 int8 | 8 | 1.55 ~ 1.75 | 8 ~ 11 s |
| Kokoro int8 | 8 | 3.6 ~ 4.0 | 更慢 |
RTF 直接翻倍,排除。
原因不复杂:Kokoro 是为 x86/NEON 优化过的模型,参数量和算子结构在 A72 上并不占优;「在别的平台快」不等于「在 RK3576 上快」。
教训:端侧选型必须回到目标板子上实测,社区口碑只能当参考。
3.2 路线 B:原厂 RKNN TTS(MMS-TTS / rknn_model_zoo)—— 方向对,但算子不配合
顺着「把 TTS 挪到 6 TOPS NPU 上」的思路,去翻了 RK 原厂的 rknn_model_zoo 和社区里的 RKNN TTS 方案,结论偏悲观:
这次调研没有白做,它帮我锁定了正确的方向:要绕开 flow,选「非 flow 的声学模型 + 可全 NPU 加速的声码器」,也就是下面的 Matcha + Vocos。
3.3 路线 C:切句流水线(还是 VITS,纯工程优化)
在换模型之前,先做了一个零成本的工程优化:把长文本切成小句,逐句合成、边合成边播放。
def split_sentences(text, first=10, hard=18):
"""按标点切小句后贪心打包成合成单元。
首块压到约 first 字,因为 VITS 非流式,首块越短出声越快;
后续块放宽到 hard 字,反正它们在播放期间后台合成。"""
out, buf = [], ""
for c in _split_clauses(text):
limit = hard if out else first
if buf and len(buf) + len(c) > limit:
out.append(buf); buf, limit = "", hard
while len(c) > limit:
k = _safe_cut(c, limit)
out.append(c[:k]); c, limit = c[k:], hard
buf += c
if buf:
out.append(buf)
return out
配合一个「后台合成线程 + 主线程顺序播放」的 PipelinedSpeaker:后台不断把句子合成成 wav,主线程按序 aplay。
效果:
| 首声 | 8 ~ 11 s | 2.5 ~ 3.5 s |
| RTF | 1.7 ~ 1.9 | 1.55 ~ 1.75(不变) |
| 块间空档 | 无 | 有,约 0.2 ~ 0.8 s |
首声降下来了,但 RTF 仍大于 1,意味着只要回答稍长,播放就会「追不上」合成,中间开始出现可听见的静音空档。这条路只能救急,不能当终点。
切句里有个细节值得抄走:_safe_cut 在切点前会回退,避免把「2026年10月21日」这种数字串劈成两半,也避免把英文单词切开。
3.4 路线 D:Matcha + Vocos(RKNN,最终方案)
架构
rkvoice-stream 0.2.0 提供了一套面向 RK3576 的 Matcha + Vocos 后端,数据流是:
文本 → 文本前端(CPU, 查表/音素化) → tokens
→ Matcha RKNN(NPU) → mel 频谱
→ Vocos RKNN(NPU) → 线性谱
→ ISTFT(CPU) → PCM 16 kHz
和 VITS 最大的区别:
- Matcha 是「条件流匹配」声学模型,推理只需 1 步 ODE,没有 VITS 那种多次 flow 变换;
- Vocos 是纯卷积声码器,整段可静态编译成 RKNN,全部落到 NPU;
- 两者都导出成固定 shape,避免动态 shape 在 RKNN 上的额外开销。
板上的两个模型:
| Matcha 声学模型 | matcha-s64.rknn | 55.5 MB | seq_len=80, x_len=64 → 599 帧 mel(≈9.58 s) | 293.6 ms |
| Vocos 声码器 | vocos-16khz-600.rknn | 28.2 MB | 600 帧输入,输出 mag/x/y [1,513,600] | 54.7 ms |
单次推理计时(rknn-toolkit-lite2 2.3.2 + librknnrt 2.3.2,NPU 950 MHz):
matcha-s64 : 293.6 ms (599 帧 ≈ 9.58 s 音频)
vocos-600 : 54.7 ms
ISTFT (CPU) : ~50 ms
说明:加载模型时那句 Query dynamic range failed … static shape RKNN model 是正常警告,静态 shape 模型本来就没有动态量化范围,忽略即可。
端到端实测:
| RTF | 0.099 ~ 0.104(约每秒音频 0.1 秒合成) |
| 首声 | 0.37 ~ 0.43 s |
| 相对 VITS 提升 | 约 15 ~ 17 倍 |
第一个坑:尾部「静音 + 孤立杂音」
固定窗口模型有个副作用:推理时按固定帧数输出,拿到音频后要按估算的 mel 帧数裁剪。而纯中文的帧数估计偏大约 30%~40%,多出来的部分会被声码器渲染成「一段静音 + 末尾一小块杂音」。
一开始我是用板载 Paraformer 回读合成音频来验音质的,原样输出是:
你好我是本地的的语音助手三
今天天气不错出去走走吧嘿
末尾的「三」「嘿」不是文本里的字,而是固定窗口多估帧产生的残响。解决办法是按能量分段,只保留最后一个足够长的语音段:
def trim_tail(samples, sr, thr=0.02, win=0.10, keep=0.15):
"""剪掉尾部静音与孤立残响。"""
n = int(win * sr)
if len(samples) < 4 * n:
return samples
frames = len(samples) // n
rms = np.sqrt((samples[:frames * n].reshape(frames, n) ** 2).mean(axis=1))
loud = rms > thr
runs, start = [], None
for i, v in enumerate(loud):
if v and start is None:
start = i
elif not v and start is not None:
runs.append((start, i)); start = None
if start is not None:
runs.append((start, frames))
long_runs = [r for r in runs if r[1] – r[0] >= 4] # ≥0.4 s 才算语音
if not long_runs:
return samples
end = min(len(samples), long_runs[–1][1] * n + int(keep * sr))
return samples[:end]
踩坑记录:第一版阈值设了 0.01,结果剪不掉 —— 因为那段杂音的 RMS 是 0.108,反而高于阈值。改成「100 ms 窗 + 能量分段 + 只保留最后一个 ≥0.4 s 的语音段」才彻底干净。
回读验证(同一个 Paraformer):
内存共三千八百九十三兆
可用二千三百四十兆
第二个坑:别关掉切句
刚上 Matcha 时我以为「合成这么快,干脆整段一次性合成」,于是把首块/块长都设成 1000 字。结果反而更差:
| 整段(first=chunk=1000) | 1.15 s | 更长 | 11.41 s(估多) |
| 按句(first=24, chunk=40) | 0.41 s | 更短 | 正常 |
原因还是那句话:非流式模型的首声 = 首块合成时间。首块越短,出声越快;Matcha 的 RTF 只有 0.1,24 个字的首块合成约 0.4 秒,正好卡在体感「即时应答」的区间。
3.5 四条路线横向汇总
| VITS 整句 | 8 ~ 11 s | 1.7 ~ 1.9 | 好 | 不可用 |
| Kokoro int8 | 更差 | 3.6 ~ 4.0 | 一般 | 排除 |
| 原厂 RKNN TTS | 未落地 | — | — | flow 算子受限,排除 |
| VITS + 切句 | 2.5 ~ 3.5 s | 1.55 ~ 1.75 | 好 | 有块间空档,仅救急 |
| Matcha + Vocos | 0.37 ~ 0.43 s | 0.10 | 好(ASR 回读可懂) | 采用 |
四、落地时绕不过的两件事
4.1 Python 版本:系统只有 3.13,而官方 RKNN wheel 只到 cp312
板端系统 Python 是 3.13.5,而且:
- 没有 pip;
- apt 源是空的(/var/lib/apt/lists 里只有 auxfiles),装不了包;
- 没有 gcc/make,源码编译也走不通;
- sudo 需要密码。
而 rknn-toolkit-lite2 官方 wheel 只有 cp37 ~ cp312,且内部是 cp312 编译的 .so,不是纯 Python 包,换版本必炸。系统 Python 又不敢动(一堆系统工具依赖它)。
最后的解法是装一个完全隔离的 CPython 3.12:
# python-build-standalone 的 install_only 包,解压即用,不碰系统 Python
# cpython-3.12.15+20261020-aarch64-unknown-linux-gnu-install_only.tar.gz(约 53 MB)
mkdir -p /userdata/rkllm/py312
tar -xzf cpython-3.12.15+20261020-aarch64-unknown-linux-gnu-install_only.tar.gz \\
-C /userdata/rkllm/py312 –strip-components=1
P=/userdata/rkllm/py312/bin/python3.12
$P -m pip install sherpa-onnx==1.10.46 numpy soundfile pyyaml rknn-toolkit-lite2==2.3.2
关键点:
- librknnrt.so 是 2.3.2、驱动 0.9.8,所以 rknn-toolkit-lite2 也必须锁 2.3.2,三个版本严格对齐;
- 整个环境装在 /userdata,与系统 Python 完全隔离,不需要 root、不需要 Docker。
4.2 espeak-ng:没有 root 也要中英混说
Matcha 的中文走词典(lexicon.txt),英文走 espeak-ng 音素化。板子上没有 root 装 deb,但 deb 本质上就是个 tar 包:
# 解包(不需要安装)
dpkg-deb -x libespeak-ng1_1.52.0+dfsg-5build1_arm64.deb /userdata/rkllm/espeak
dpkg-deb -x libpcaudio0_1.3-1build1_arm64.deb /userdata/rkllm/espeak
dpkg-deb -x libsonic0_0.2.0-13build2_arm64.deb /userdata/rkllm/espeak
# 把 .so 拷到一个固定目录
mkdir -p /userdata/rkllm/lib
# libespeak-ng.so.1 / libpcaudio.so.0 / libsonic.so.0
然后不要指望 LD_LIBRARY_PATH(子进程/不同加载时机容易失效),直接在进程启动时按绝对路径预加载:
_ESPEAK_LIB = "/userdata/rkllm/lib"
for _so in ("libpcaudio.so.0", "libsonic.so.0", "libespeak-ng.so.1"):
try:
ctypes.CDLL(os.path.join(_ESPEAK_LIB, _so), mode=ctypes.RTLD_GLOBAL)
except OSError:
pass
RTLD_GLOBAL 让后续 CDLL("libespeak-ng.so.1")(rkvoice-stream 内部按 SONAME 加载)直接命中已加载的库。
验证:
Initialize rc=22050
SetVoice(en-us) rc=0
hello -> həlˈoʊ
Python -> pˈaɪθən
中英混说实测 RTF 0.071,中英切换正常。
踩坑:libespeak-ng-libespeak1 是个 2974 字节的过渡空包,里面只有一个 libespeak.so.1 符号链接,装上没有任何用。要装 libespeak-ng1(本文用的 1.52.0)。
五、最终技术路线
5.1 整体架构
┌──────────── CPU ────────────┐ ┌──────── NPU ────────┐
麦克风 → KWS(小智小智) → VAD → Paraformer ASR → RKLLM(Qwen2.5-0.5B) → 回复文本
│
┌────────────────────────┘
▼
切句(首块24字/后续40字) → 后台合成线程
│
Matcha RKNN ──▶ Vocos RKNN ──▶ ISTFT(CPU)
│
trim_tail 剪尾
│
aplay 顺序播放
5.2 关键代码:把 Matcha 适配成 sherpa 风格的 TTS
上层编排(切句、流水线播放、写 wav)本来是照着 sherpa-onnx 的 generate() 接口写的,所以只需要一个薄适配层,不用改整机代码:
class MatchaTTS:
def __init__(self, model_dir="/userdata/rkllm/rksv"):
sys.path.insert(0, "/userdata/rkllm/rksv/site")
os.environ.setdefault("TTS_MODEL_DIR", model_dir)
os.environ.setdefault("MATCHA_MODEL", "models/matcha-s64.rknn")
os.environ.setdefault("VOCOS_MODEL", "models/vocos-16khz-600.rknn")
from rkvoice_stream.backends.tts.matcha import create_rknn_tts_backend
self.engine = create_rknn_tts_backend(model_dir)
self.engine.load()
self.sample_rate = 16000
def generate(self, text, sid=0, speed=1.0):
audio, _ = self.engine.synthesize(text, speed=speed)
return _Speech(trim_tail(audio, self.sample_rate), self.sample_rate)
5.3 切句参数按后端区分
同一套切句逻辑,VITS 和 Matcha 的最优块长完全不同:
if args.tts == "matcha":
first_len, chunk_len = 24, 40 # RTF 0.1,块大一点减少拼接感
else:
first_len, chunk_len = 10, 18 # RTF 1.7,首块必须够短
同时保留 –tts vits 回退开关,换模型出问题时一行参数就能退回 CPU 方案。
5.4 整机时延预算(0.5B + Matcha,实测)
整机链路已经在板子上跑通,ASR 与意图工具层的实现细节留到后续那篇,这里只看时间花在哪。
| ASR(Paraformer,整句) | 0.18 s |
| LLM(0.5B,短回答,含首 token) | 0.60 s |
| TTS 首声 | 0.41 s |
| 合计(说完话 → 听见第一个字) | 约 1.2 s |
长回答(两三句)的 TTS 首声约 0.75 s,因为首块仍然是 24 字。
冷启动注意:LLM 装载约 1.6 s、Matcha+Vocos 装载约 1.2 s、Paraformer 装载约 8.5 s。做常驻服务时只装一次,不要在每轮对话里重新加载。
5.5 怎么跑
# 实时唤醒闭环(默认 Matcha+Vocos)
/userdata/rkllm/py312/bin/python3.12 -u /userdata/rkllm/assistant.py
# 不开口,直接喂一句话验证 LLM + TTS + 播放
/userdata/rkllm/py312/bin/python3.12 -u /userdata/rkllm/assistant.py \\
–text "现在温度多少"
# 用录好的 wav 走完整链路(不含唤醒)
/userdata/rkllm/py312/bin/python3.12 -u /userdata/rkllm/assistant.py \\
–file /userdata/rkllm/asr_out/cmd_20261017_184654.wav
# 回退到 CPU VITS
/userdata/rkllm/py312/bin/python3.12 -u /userdata/rkllm/assistant.py –tts vits
六、附带解决:让数字「念得像中文」
换完模型后又冒出一个新问题:TTS 前端会把 ASCII 数字按英文音素化。
现在是 2026-10-21 22:05
→ "twenty twenty-six, ten, twenty-one, twenty-two oh five"
时间和系统状态是最常问的,这么念非常出戏。解决办法不是改 TTS,而是在工具层就把数字写成汉字(TTS 只负责念,不负责理解):
_CN_DIGITS = "零一二三四五六七八九"
def _cn_year(y):
return "".join(_CN_DIGITS[int(d)] for d in str(y))
def cn_date(now):
return "%s年%s月%s日 星期%s" % (
_cn_year(now.year), _cn_under100(now.month), _cn_under100(now.day),
"一二三四五六日"[now.weekday()])
def cn_time(now):
# 22:05 → 晚上十点零五分
...
另外三个函数负责其它场景:
| cn_int | 整数(内存、条数、算式) | 3893 → 三千八百九十三 |
| cn_dec | 小数(温度、负载) | 45.2 → 四十五点二;3.50 → 三点五 |
| cn_digits | 数字串逐位读(IP) | 192.168.0.103 → 一九二点一六八点零点一零三 |
工具层改完后,同一条链路实测:
| 现在几点 | 现在是 二零二六年十月二十一日 星期三,晚上十点二十二分 |
| 看看温度 | 温度 soc 五十点八 度,bigcore 五十二点七 度,npu 四十九点九 度 |
| 内存还剩多少 | 内存共 三千八百九十三 兆,可用 二千三百四十 兆 |
| 本机 IP | 本机 IP 一二七点零点零点一、一九二点一六八点零点一零三 |
| 看看待办 | 你有 一 条待办:一 明天买牛奶 |
| 20 分钟后提醒我吃药 | 好的,二十分钟后会提醒你吃药 |
| 12 乘以 8 等于多少 | 十二 乘以 八 等于 九十六 |
再用 Paraformer 回读合成出来的 wav,确认发音是中文而不是英文:
内存共三千八百九十三兆
可用二千三百四十兆
温度 soc 五十点八度
十二乘以八等于九十六
首声仍是 0.42 ~ 0.43 s,RTF 0.14 ~ 0.18 —— 中文读法没有带来额外开销。
七、踩坑清单
| matcha.tar.bz2 是截断坏包 | 14.7 MB,解压报错(真实约 79 MB) | 重新完整下载;解压后应有 76 MB 资源目录 |
| 模型路径少一层 models/ | 后端静默不加载,_matcha_backend is None,没有任何警告 | 默认路径写成 models/matcha-s64.rknn |
| 尾部残响剪不掉 | 阈值 0.01,杂音 RMS 0.108 | 100 ms 窗能量分段,保留最后一个 ≥0.4 s 语音段 |
| 关掉切句反而更慢 | 首声 1.15 s,音频估长到 11.41 s | 保持按句切分(24/40) |
| libespeak-ng-libespeak1 | 2974 字节过渡包,装上无效 | 用 libespeak-ng1 + dpkg-deb -x 解包 |
| pkill -f assistant.py | 把自己的 SSH 会话一起杀了 | 用 ps -eo pid,args | grep ssistant | grep -v grep 精确找 PID |
| 系统 Python 3.13 | 装不了 rknn-toolkit-lite2,无 pip/apt/gcc | python-build-standalone CPython 3.12 隔离环境 |
| Kokoro 看起来很美 | 实测 RTF 3.6~4.0,比 VITS 还慢 | 任何模型都必须在目标板上实测 |
八、总结与下一步
下一步可以做的:
- 真正的流式 TTS:目前 Matcha 是固定窗口整段出音(一次吐出 4 秒),要更低延迟需要能分块喂声码器的流式实现;
- 时长估计修正:纯中文的 mel 帧数估计偏大 30%~40%,现在靠 trim_tail 兜底,后续可以按文本长度做线性校正,减少无效计算;
- 多说话人 / 音色切换:Vocos 本身与音色无关,换 Matcha 说话人 embedding 即可。
本文为 Quectel Pi M2 离线语音助手系列实践第四篇:TTS 低延迟优化(VITS / Kokoro / Matcha+Vocos 实测对比)。
系列前作:板载麦克风与蓝牙播放 | 中文语音唤醒(KWS) | RKLLM NPU 推理
下一篇(计划中):ASR(Paraformer)+ 意图工具层 + 整机闭环
网硕互联帮助中心





评论前必须登录!
注册