在muse.ai上测试通过,可以将此文章交给你的AI agent进行配置。
用 Kaldi 音素识别验证 TTS 发音:复现教程
这个方法解决什么问题
TTS 读多音字、生僻字时经常读错,人工试听不可靠也不可规模化。这个方法把 TTS 音频交给 Kaldi 做语音识别,不看它识别出的汉字,而是读它解码路径上的音素(phoneme)序列,再与目标字的正确读音逐项比对,判定 TTS 到底读了什么音。
为什么不直接看识别出的汉字:词级识别受语言模型影响,一个词读音错了,识别器可能仍因上下文输出「看起来对」的词。音素来自声学解码路径,同一个词在词典里的不同发音变体之间是声学在竞争,选出的音素序列反映的正是实际发出的声音。
下文以一个真实案例贯穿:检验某 TTS 是否把「攒竹穴」的「攒」读对(应读 cuán,不应读 zǎn)。方法本身适用于任何多音字或易错字。
环境要求
- Linux x86_64(实测 Ubuntu 24.04),CPU 即可,不需要 GPU
- 内存 4 GB 以上,磁盘预留约 4 GB(模型 1.3 GB + Kaldi 环境约 2 GB)
- 已装:curl、unzip、ffmpeg、python3
- 待测音频:任何 TTS 生成的 wav 均可;也可以用真人录音先验证流程本身
第一步:装 Kaldi(CPU 版)
不要从源码编译,几小时起步且新系统上容易失败。conda-forge 有现成的 CPU 构建:
mkdir -p ~/tts-verify && cd ~/tts-verify
curl -fsSL -o miniforge.sh https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-Linux-x86_64.sh
bash miniforge.sh -b -p ~/tts-verify/miniforge3
export PATH=~/tts-verify/miniforge3/bin:$PATH
conda create -y -n kaldi -c conda-forge "kaldi=5.5.1172=cpu*" python=3.11
之后每个终端会话先设好 Kaldi 的路径(后文所有命令都依赖这两个变量):
export KB=~/tts-verify/miniforge3/envs/kaldi/bin
export EL=~/tts-verify/miniforge3/envs/kaldi/lib
export PATH=$KB:$PATH
export LD_LIBRARY_PATH=$EL/fst:$EL:$LD_LIBRARY_PATH
LD_LIBRARY_PATH 里必须带上 $EL/fst,否则读 FST 时会报 olabel_lookahead-fst.so: cannot open shared object file 一类错误——OpenFST 的插件在那个子目录里。
验证装好了:
compute-mfcc-feats --help 2>&1 | head -3
能打印用法即算成功。
第二步:下载中文声学模型
用 Vosk 的中文大模型 vosk-model-cn-0.22。它本质上就是一个打包好的 Kaldi chain 模型:am/final.mdl 是声学模型,graph/HCLG.fst 是解码图,graph/words.txt 是词表,另带 i-vector 提取器,正好被 Kaldi 自带工具直接使用。
cd ~/tts-verify
curl -fsSL -O https://alphacephei.com/vosk/models/vosk-model-cn-0.22.zip
unzip vosk-model-cn-0.22.zip
export M=$PWD/vosk-model-cn-0.22
注意不要图省事下小模型 vosk-model-small-cn-0.22:小模型的解码图是 Vosk 私有的 lookahead 格式,上游 Kaldi 的解码器读它会直接段错误(退出码 139),大模型的图是标准格式,没有这个问题。
第三步:准备两句音频
需要两条音频,采样率统一转成 16 kHz 单声道:
- 测试句:包含目标字,放在自然语境里,最好出现两次以上便于互证。本例:
攒竹穴在眉头内侧,按压攒竹穴可以缓解头痛。 - 标定句:由读音无歧义的词组成,用来标定音素编号(原因见第五步)。设计原则是覆盖目标音节的每个部件的对照项:目标的声母、易混声母、各声调的韵母、以及与正确读音同结构的音节。本例为判定 cuán 而设计:
全体村民,咱们侧身团坐,敢说敢做,泉水长流。
其中 全/团 提供 uan2 的完整形态,咱 提供 z+an2,敢 提供 an3,在测试句里的 在/做 提供声母 z,村/侧 提供声母 c。换任何目标字时照此办理:先写出正确读音和最可能的错读,把两者有区别的每个部件都找一个无歧义的词作锚点。
用你的 TTS 分别合成两句(任意引擎),然后转格式:
ffmpeg -y -i test_raw.wav -ar 16000 -ac 1 test_16k.wav
ffmpeg -y -i calib_raw.wav -ar 16000 -ac 1 calib_16k.wav
printf "test %s/test_16k.wav\ncal %s/calib_16k.wav\n" "$PWD" "$PWD" > wav.scp
printf "test test\ncal cal\n" > spk2utt.txt
wav.scp 是 Kaldi 的音频清单(每行:语句 id + 路径);spk2utt.txt 是说话人到语句的映射,单句单说话人时照上面写死即可。注意解码命令里 spk2utt 是必传的位置参数,漏掉它 Kaldi 会把后面的 lattice 文件当成 spk2utt 去读并报一个看起来毫不相干的错。
第四步:解码,提取词序列和音素序列
先写 i-vector 配置(把模型路径展开写死):
cat > ivector.conf <<EOF
--cmvn-config=$M/ivector/online_cmvn.conf
--ivector-period=10
--splice-config=$M/ivector/splice.conf
--lda-matrix=$M/ivector/final.mat
--global-cmvn-stats=$M/ivector/global_cmvn.stats
--diag-ubm=$M/ivector/final.dubm
--ivector-extractor=$M/ivector/final.ie
--num-gselect=5
--min-post=0.025
--posterior-scale=0.1
EOF
解码(9 秒左右的句子,CPU 上数秒完成):
online2-wav-nnet3-latgen-faster \
--word-symbol-table=$M/graph/words.txt \
--config=$M/conf/model.conf \
--mfcc-config=$M/conf/mfcc.conf \
--ivector-extraction-config=ivector.conf \
--do-endpointing=false \
$M/am/final.mdl $M/graph/HCLG.fst \
ark,t:spk2utt.txt scp:wav.scp ark:lat.ark
日志结尾出现 Decoded 2 utterances, 0 with errors 即成功。然后提取最优路径。注意 lattice-best-path 的位置参数顺序是〈lattice 输入〉〈转写输出〉〈对齐输出〉,第二个文件不是 lattice 本身:
# 词序列
lattice-best-path --word-symbol-table=$M/graph/words.txt \
ark:lat.ark ark,t:best_words.ark ark,t:ali_words.txt
# 先生成一个占位音素符号表 (编号总数取自 word_boundary.int 的行数)
N=$(wc -l < $M/graph/phones/word_boundary.int)
python3 -c "print('\n'.join(f'P{i} {i}' for i in range(1, $N+1)))" > phones_dummy.txt
# 音素序列: 先把词 lattice 转成音素 lattice, 再取最优路径
lattice-to-phone-lattice $M/am/final.mdl ark:lat.ark ark:phonelat.ark
lattice-best-path --word-symbol-table=phones_dummy.txt \
ark:phonelat.ark ark,t:best_phones.ark ark,t:ali_phones.txt
本例的预期输出(复现时用来核对流程是否装对):
best_words.ark:
test 攒竹 血 在 梅某 内侧 按压 攒竹 血 可以 缓解 头痛
cal 全体 村民 咱们 侧身 团座 敢 说 敢 做 全 水 长流
best_phones.ark (test 句开头一段):
test 1 631 77 637 556 579 628 1 631 60 ...
词序列里出现「梅某」(原句是眉头)这类错误是识别器自身的问题,不必管——判定只用目标字所在位置的音素,见下一步。「血」是 穴 的位置:穴 xué 与 血 xuè 在这个模型的解码路径上同属一条音素路径,本方法分不开,这一处就不下结论,报告里要如实写明。
第五步:判定——锚点法
模型包里没有带符号名的 phones.txt,音素只有一个编号。但编号本身就够用,前提是知道每个编号对应什么音,这就是标定句的作用。
编号的结构可以从 graph/phones/word_boundary.int 读出来:共 646 行,前 10 个是 SIL/SPN 两个特殊音素(各占 5 行),其后每 4 行是一组,依次是同一音素在词首、词尾、词中、独占时的位置变体(_B/_E/_I/_S)。因此:
base 编号 = (phone id - 11) // 4 (id > 10 时)
同一 base 的四个位置变体是同一个音素,判定只比 base。
随附的 segment.py 按这个规则把音素序列切回词,并打印每个词的 base 编号:
python3 segment.py $M/graph/words.txt best_words.ark best_phones.ark
从输出里先读锚点(本例实测值):
| 锚点 | 出处 | phone id | base |
|---|---|---|---|
| 声母 z | 在、咱、做 的首音素 | 631 | 155 |
| 声母 c | 村、侧 的首音素 | 495 / 497 | 121 |
| 韵母 an3 | 敢 的末音素 | 76 / 77 | 16 |
| 韵母 an2 | 咱、全、团 的末音素 | 72 / 73 | 15 |
再看正确读音的预期形态。全 quán 的音素是 475 589 72,即 q + 介母 + an2 三段;团 tuán 同理。cuán 若读对,应是「声母 c(base 121)+ 介母 + an2(base 15)」共 3 个音素。
实测「攒竹」两处完全一致:631 77 637 556。逐项对:攒 = 631(base 155,是 z,不是 c)+ 77(base 16,是 an3,不是 an2),且只有 2 个音素、没有介母。三项全部指向同一个结论:实际读的是 zǎn。竹 = base 156(zh)+ base 136(u2),读对了。
判定规则可以一般化:把目标音节的实测音素序列(base 列表)与两个候选读音的预期序列逐项比,声母、韵母、声调载体、音素个数四项里有一项明确对上错读一侧、且不错读侧无法解释,就算判定成立;含糊或两边都解释不了时,结论写「不判定」,不要硬判。
第六步:N-best 交叉验证(建议做)
只看最优路径有可能被语言模型带偏。再取 10 条候选路径,检查目标位置的音素形态分布:
lattice-to-nbest --n=10 ark:lat.ark ark:nb.ark
lattice-to-phone-lattice $M/am/final.mdl ark:nb.ark ark:nbphone.ark
lattice-best-path --word-symbol-table=phones_dummy.txt \
ark:nbphone.ark ark,t:nb_phones.txt ark,t:nb_ali.txt
本例 10 条候选里,两处「攒」的声母全部是 631(z),变体只有 赞助、站住、攒足 等 z/zh 声母的词,没有一条出现 c 声母或「c+介母+an2」的形态。候选一致地不支持 cuán,判定才算稳。
常见问题
- 解码器段错误(退出码 139):多半是用了小模型的 lookahead 图,换大模型(第二步的说明)。
- 报
Failed to open stream或把 lattice 文件当输入读:spk2utt参数漏传或位置放错,参数顺序见第四步。 fst相关 .so 加载失败:LD_LIBRARY_PATH没带$EL/fst,见第一步。- 音素序列和词数对不上:
segment.py按位置变体切词,若两边词数不一致,先检查词序列里是否有单音素词或漏识别,不要强行对齐后下判定。 - 内存不足:HCLG.fst 有 534 MB,解码时整体占用约 1–2 GB;低于 4 GB 内存的机器先关掉其他大进程。
- 换了别的中文 Kaldi 模型:base 编号的分组规则(4 个一组、特殊音素占位)来自这个模型的 word_boundary.int,其他模型行数和分组可能不同,套用前先核对该文件,不要直接沿用本例的编号和锚点数值——锚点必须用你自己那次解码的输出重新标定。
这个方法的边界
- 它判定的是「声学上更像哪个读音」,不是声学真值。TTS 音质差、目标字被吞音或语速过快时,音素路径本身可能不可靠,此时应先用真人录音复测同一句,确认识别器在清晰发音下能给出正确音素,再回头判 TTS。
- 语言模型的偏置只能靠 N-best 分布缓解,不能完全消除;目标字在句中的上下文越不常见,越要看候选分布而不是只看最优一条。
- 同音素路径的字(如本例的 穴/血)这个方法天然分不开,需要声调或音高层面的额外分析,不在本教程范围内。
文件清单
REPORT.md:本案例的完整判定报告segment.py:按词切分音素序列并打印 base 编号analyze.py:本案例的锚点判定(数值写死在本案例上,换目标字时按第五步重写)cuanzhu_test.wav、calib.wav:本案例的测试与标定音频