跳到主要内容

创建日期:2026-10-13 | 最近更新:2026-10-13 事实核对基于 sherpa-onnx 1.13.8(sherpa-onnx-node;node v24.19.0、Linux x64)。TTS 与唤醒词的所有耗时、命中结果为同一次 run.sh 的原始输出,脚本留档于 source/sherpa-onnx-lab/。

sherpa-onnx 101(第 4 篇 · 说话与待机):本地 TTS 与自定义唤醒词

前两篇都在讲"听"。这一篇讲另外两件事:

  • 让模型说话(TTS)—— 本地合成的水平到哪了,什么时候能替代云服务;
  • 让模型在你没说话的时候听着(KWS 唤醒词)—— 怎么用一句话把「打开控制台」变成本地命令。

这两件事凑在一起的收益很实在:本地 TTS + 本地唤醒词 + 本地识别 = 一个完全离线的语音助手,没有一行网络请求。

0. 先给结论​

#结论证据
1Kokoro 多语言模型一个模型搞定中英混读,54 个音色,24 kHz 输出§1.1
2本地 TTS 是**"刚好实时"**:合成 4.527 秒音频花 4.539 秒 CPU,RTF 1.0025§1.1
3TTS 不是确定性的:同一段文本合成两次,时长差不到 1 毫秒,但波形逐点差异明显(最大 0.60)§1.2
4唤醒词的算力几乎免费:3M 参数模型 RTF 0.029(34 倍实时),单线程§2.2
5中英双语唤醒词模型能在连续英文语流里听出 LIGHT UP、LOVELY CHILD,中文名字也照认(9 段里命中 6 段)§2.2
6自定义唤醒词三步搞定,但英文词有两个静默的坑:必须全大写,且必须在 CMU 词典里§2.3
7用 TTS 造唤醒语音再让 KWS 去听,3 个中文唤醒词全部命中,负样本不误触发§3

1. 本地 TTS:Kokoro 实测​

1.1 基本盘​

const tts = new sherpa.OfflineTts({
model: {
kokoro: {
model: '.../kokoro-multi-lang-v1_0/model.onnx',
voices: '.../voices.bin',
tokens: '.../tokens.txt',
dataDir: '.../espeak-ng-data',
lexicon: '.../lexicon-us-en.txt,.../lexicon-zh.txt', // 英文 + 中文两份词表,逗号分隔
},
numThreads: 2,
provider: 'cpu',
debug: false,
},
maxNumSentences: 1,
});

const audio = tts.generate({
text: '中英文语音合成测试,This is generated locally.',
generationConfig: new sherpa.GenerationConfig({ sid: 48, speed: 1.0, silenceScale: 0.2 }),
});
sherpa.writeWave('out.wav', { samples: audio.samples, sampleRate: audio.sampleRate });

实测:

模型采样率 : 24000 Hz
音色数量 : 54
文本 : 把 useEffect 的依赖数组补上,然后跑一遍 vitest。
3 次耗时 : 4.590 / 4.590 / 4.539 s
音频时长 : 4.527 s
最快一次 : 4.539 s RTF = 1.0025

这里最该记住的是 RTF ≈ 1.00。 前面几篇里 ASR 的 RTF 是 0.03~0.13,VAD 是 0.0085——都是"几十倍实时"。而 TTS 刚好卡在实时线上:想合成 10 秒语音,就要占满 10 秒 CPU。

这个数字直接决定了工程做法:

  • ❌ 别等整段合成完再播。用户点"朗读",会在 4.5 秒内看不到任何反应(长文本更糟)。
  • ✅ 分句合成 + 边合成边播:把长文本按标点切成句,一句一句 generate(),第一句出来就开始播,后面的在播放时并行合成。maxNumSentences 这个配置就是干这个的(设成 1 表示一次只合成一句)。

同时要认清 内存代价:Kokoro 加载后常驻 496 MB(第 0 篇的账)。它是这套技术栈里最重的一环,比识别模型还重。

1.2 一个容易忽略的事实:TTS 不是确定性的​

同一段文本、同样的参数,连续合成三次:

det-1.wav 24000Hz 4.5279s
det-2.wav 24000Hz 4.5282s
det-3.wav 24000Hz 4.5276s

两次合成:max|Δ| = 0.6046 平均|Δ| = 0.0433

时长几乎一样(差不到 1 毫秒),但每个采样点的值都不同。 三次的 md5 也各不相同。

两个实际影响:

  1. 写回归测试时,别断言音频文件的哈希。 要断言就断言时长、音量包络这类稳定特征。
  2. 这也解释了我在写这一系列时遇到的一个现象:vitest 那句合成语音丢给识别器,第一次跑出来首字是「八」,重跑一次变成「BUT」——两个音在合成里本来就不同。而我实测过识别器对同一份音频是确定的(同一文件跑三次结果字字一致)。不确定的那一半是 TTS,不是 ASR。

2. 唤醒词:让它在待机时听着​

2.1 它是什么​

官方文档把原理说得比"唤醒词"这个词本身准确得多——KWS 就是一个小到极致的 ASR,只不过它的输出被约束成"你要的关键词或者空":

Basically, an open vocabulary keyword spotting system is just like a tiny ASR system, but it can only decode words/phrases in the given keywords. For example, if the given keyword is HELLO WORLD, then the decoded result should be either HELLO WORLD or empty. As for open vocabulary (or customized), it means you can specify any keywords without re-training the model.

两个可调参数控制"灵不灵"和"误不误报"(官方原文):

To make it able to balance between the triggered rate and false alarm, we introduce two parameters for each keyword, boosting score and trigger threshold. The boosting score works like the hotwords recognition, it help the paths containing keywords to survive beam search... The trigger threshold defines the minimum acoustic probability of decoded sequences that can be triggered, it is a float value between 0 to 1, the lower this threshold is the easier the corresponding keyword will be triggered.

调参方向记牢:

现象调什么
喊了没反应(漏检)提高 boosting score(:2.0)或降低 threshold(#0.35)
没喊也触发(误报)降低 boosting score 或提高 threshold

2.2 先看它能做到什么程度​

中文唤醒词(zipformer 3.3M,建模单元是声母+韵母拼音):用模型自带的 8 个关键词跑 7 段真实录音:

0.wav 5.61s 耗时 0.178s RTF 0.032 命中: (无)
1.wav 5.15s 耗时 0.151s RTF 0.029 命中: (无)
2.wav 4.52s 耗时 0.141s RTF 0.031 命中: (无)
3.wav 8.03s 耗时 0.246s RTF 0.031 命中: 文森特卡索 , 法国
4.wav 4.60s 耗时 0.149s RTF 0.032 命中: 蒋友伯 , 女儿
5.wav 4.15s 耗时 0.127s RTF 0.031 命中: 周望军 , 落实
6.wav 3.55s 耗时 0.113s RTF 0.032 命中: 朱丽楠 , 见面会
有命中的文件: 4/7

4/7。前 3 段没命中是正常的——那几段本来就没说这些名字。关键是后 4 段每段都命中了两处,说明它能在一段话里检出多个关键词,而且是逐次触发的(getResult().keyword 每命中一次就返回一次,循环里攒起来)。

中英双语唤醒词(zipformer 3M,2025-12 发布,建模单元是 CMU 音素 + 声母韵母):

zh_0..zh_6.wav 同上一批中文音频,命中情况完全一致
en_0.wav 6.63s 耗时 0.193s RTF 0.029 命中: LIGHT_UP
en_1.wav 16.71s 耗时 0.481s RTF 0.029 命中: LOVELY_CHILD
有命中的文件: 6/9

这两个英文命中含金量很高:LIGHT_UP 出现在 "the yellow lamps would light up here and there" 这种连续语流里;LOVELY_CHILD 更是藏在一段 16.7 秒、语速正常的英文朗读中间。它不是"听到孤立词才响",而是真的在流式扫全文。

对一个前端来说这意味着:你可以把 OPEN CONSOLE、RUN TESTS 这类词设成唤醒词,做一个完全本地的语音指令入口。

2.3 自定义唤醒词:三步 + 两个坑​

唤醒词文件长这样(左半是 token 序列,: 后是加分,# 后是触发阈值,@ 后是原文):

D IH0 P L OY1 @DEPLOY
OW1 P AH0 N K AA1 N S OW0 L @OPEN_CONSOLE
d ǎ k āi k òng zh ì t ái @打开控制台

手写这些 token 不现实,用官方 CLI 生成(pip install sherpa-onnx,需要 click、pypinyin、sentencepiece):

# 输入:每行一句原始词,可以带分数和阈值
cat > keywords-in.txt <<'EOF'
打开控制台 @打开控制台
运行测试 :2.0 #0.3 @运行测试
OPEN CONSOLE @OPEN_CONSOLE
EOF

sherpa-onnx-cli text2token \
--tokens ./tokens.txt \
--tokens-type phone+ppinyin \
--lexicon ./en.phone \
keywords-in.txt keywords.txt

输出:

d ǎ k āi k òng zh ì t ái @打开控制台
y ùn x íng c è sh ì :2.0 #0.3 @运行测试
OW1 P AH0 N K AA1 N S OW0 L @OPEN_CONSOLE

坑一:英文词必须全大写。 写 deploy 会被直接跳过(目录词典是大写的):

Word deploy not in lexicon and it is not a CJK character, skipping text: deploy.

改成 DEPLOY 就正常。注意它是"跳过"而不是报错——如果你不看输出,会发现唤醒词少了一条。

坑二:英文词必须在 en.phone 这个 CMU 词典里。 CONSOLE、TESTS、BUILD、DEPLOY 都在;你自造的品牌名/缩写(比如 TQSDK)大概率不在,会被同样跳过。这种情况只能:

  • 换个拼法接近的常见词;
  • 或者用「中文音译」当唤醒词(拼音路径没有词典限制)。

第三个不那么像坑但很容易写反的地方:--tokens-type 要跟模型对齐。中文 ppinyin 模型用 ppinyin,中英双语这个用 phone+ppinyin(还要给 --lexicon),BPE 模型用 bpe(要给 --bpe-model)。选错了会得到一堆 OOV 或者空输出。

3. 闭环实验:TTS 造音频 → KWS 去听​

我没有"用正常语速说『打开控制台』"的现成录音,于是用第 1 节的 Kokoro 合成出来,再喂给 KWS。好处是任意唤醒词都能自造测试集:

TTS 说的内容 KWS 听到的 判定
zh-open-console 打开控制台 √ 命中
zh-run-test 运行测试 √ 命中
zh-front-debug 前端调试 √ 命中
en-open-console (无) × 漏检
zh-negative (无) √ 负样本未误触发
通过 4/5

三个中文唤醒词全部命中,负样本("今天天气不错,适合写代码")没有被误触发——后者和前者一样重要,说明阈值没有松到乱响。

英文那个漏检,我追查了一下,原因不在 KWS:把这段合成音频丢给识别器,它听出来的是 OPEN CANCEL。也就是说 Kokoro 把 "console" 念得接近 "cancel"(名词/动词重音不同),压根不是 KWS 认不出来。反证是 §2.2 里真人的英文录音 LIGHT UP / LOVELY CHILD 都命中了。

这也是个方法论提醒:用合成语音做测试时,漏检要先怀疑 TTS 的发音,别急着调 KWS 参数。

4. 给编程场景的唤醒词表​

结合上面的实测,一份可以直接用的清单:

唤醒词token 来源备注
打开控制台 / 打开终端拼音(无需词典)最顺手的一类:全中文
运行测试 / 跑一下测试拼音
前端调试 / 看一眼日志拼音
提个 issue / 跑个构建拼音中英夹杂但英文部分写作汉字
OPEN CONSOLE / RUN TESTSCMU 音素全大写;词要在 en.phone 里
DEPLOY / BUILDCMU 音素

三条经验:

  1. 优先选纯中文唤醒词——没有大小写和词典的坑,拼音路径最稳。
  2. 2~4 个音节最合适。单音节("停")误报率高;长句("帮我打开控制台然后运行测试")漏检率高。
  3. 准备一组负样本音频(日常闲聊、代码评审录音),实测误报率再上线。官方的 threshold 默认 0.25,这个值在你的环境里大概率需要调。

5. 一套完全离线的语音助手长什么样​

把第 0~4 篇的东西拼起来,就是这样一个东西——全程零网络请求:

麦克风
│
├─ Silero VAD(74 MB,RTF 0.0085)———— 静音时不干活
│
├─ 待机态:KWS(93 MB,RTF 0.029) ←── 「打开控制台」
│ │ 命中
│ ▼
├─ 唤醒态:流式 ASR(324 MB,RTF 0.12)── 边说边出字
│ │ 加上热词 + 同音词替换(第 3 篇)
│ ▼
└─ 回应:TTS(496 MB,RTF 1.00)—— 分句合成,边合成边播

内存上的关键设计是别同时加载所有模型:待机时只需要 VAD + KWS(约 170 MB),被唤醒后再加载大的识别模型,TTS 按需懒加载。这套模型全部常驻大约 1 GB,在中低端设备上必须做这种"按状态加载/卸载"。

下一篇是最后一篇:把这一切搬进浏览器,看看代价到底是多少。