在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 单声道:

  1. 测试句:包含目标字,放在自然语境里,最好出现两次以上便于互证。本例:
    攒竹穴在眉头内侧,按压攒竹穴可以缓解头痛。
  2. 标定句:由读音无歧义的词组成,用来标定音素编号(原因见第五步)。设计原则是覆盖目标音节的每个部件的对照项:目标的声母、易混声母、各声调的韵母、以及与正确读音同结构的音节。本例为判定 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:本案例的测试与标定音频