最近一直在折腾语音识别的东西,本来想直接用现成的在线API,但看了一圈,要么受限于网络和并发,要么就是定制性不够。翻来覆去,最后还是绕回到Kaldi上。这东西虽然老,但确实是绕不开的基础设施,GitHub上九千多star不是白来的,学术界和工业界的很多系统底层都是它。我把自己从零开始啃Kaldi的笔记整理了一下,从环境搭建到跑通第一个中文识别系统,再到那些网上不常写清楚的坑,希望给正在入坑的朋友省点时间。
这篇笔记适合两类人:一类是刚接触ASR、想系统了解语音识别链路的学生或工程师,另一类是已经在用其他工具(比如Whisper、讯飞SDK),但想深入底层原理、自己做定制训练的开发者。Kaldi的文档写得实在是让人头大,我踩了不少坑才把整个流程摸顺,这里尽量用大白话把关键环节讲清楚。
1. 为什么现在还要学Kaldi?先搞清楚工具选型
先说个网上经常吵的问题:都有Whisper、都有那么多在线SDK了,为什么还要学Kaldi这种“老古董”?
1.1 Kaldi的定位:不是拿来即用的产品,而是研究平台
Kaldi不是一个装好就能用的“语音识别软件”,它更像是一套完整的语音识别研究工具箱。它给你提供的是:特征提取、声学模型训练、解码器、语言模型工具链,以及一套设计得相当精巧的脚本体系。你需要自己去跑训练流程、调参数,甚至改代码。
这跟调用讯飞或者百度的在线语音识别API完全是两码事。在线API方便,但你是黑盒:识别错了你不知道为什么错,想针对特定场景优化也无从下手。最近看到有人在问“asr语音转文字有ubuntu的sdk包吗”,其实这就是两种思路的差别——你要的是一个能离线跑的、自己能控制的识别系统,不仅仅是调用云端接口。Kaldi给的就是这种自主性,它支持完全离线部署,不依赖外网,对数据隐私敏感的场景特别重要。
1.2 Kaldi、Whisper、在线SDK怎么选
我做了个对比表,方便你判断自己到底该学哪个:
| 方案 | 定制性 | 部署方式 | 上手难度 | 适合场景 |
|---|---|---|---|---|
| Kaldi | 高,可改底层模型和训练流程 | 可离线、可嵌入式 | 高 | 需要定制声学模型、离线部署、学术研究 |
| Whisper | 中,可以微调但生态较重 | 可离线,需较大内存 | 中 | 通用场景、直接使用预训练模型 |
| 在线SDK | 低,纯黑盒 | 必须联网 | 低 | 快速原型、对效果要求不苛刻 |
所以结论很简单:如果你想快速做个demo,别碰Kaldi,直接用在线SDK;但如果你想认真做语音识别,理解声学模型怎么训练、解码图怎么构建,Kaldi是绕不过去的一课。最近有人在讨论“llamacpp 部署asr”,说明端侧部署ASR成了新趋势,而Kaldi训练出来的模型是可以转成端侧推理格式的,这个技术链路Kaldi依然有价值。
2. Kaldi核心概念扫盲:这些不搞懂,后面寸步难行
我在刚开始学Kaldi的时候最大的困惑就是:那么多目录、那么多脚本,到底先看哪个?其实核心只需要弄明白四件事:数据怎么组织、特征怎么提、模型怎么训练、解码图怎么建。
2.1 数据准备的核心:scp、ark与Kaldi的数据目录
Kaldi里最基础的数据单元不是单个文件,而是一个数据目录(data dir)。一个典型的数据目录长这样:
data/train/ ├── wav.scp # 音频文件列表 ├── text # 标注文本 ├── utt2spk # 每条语音对应说话人 ├── spk2utt # 每个说话人对应哪些语音 └── ...这里wav.scp是这个系统的灵魂,它的格式是:
utterance_id_001 /home/user/audio/001.wav utterance_id_002 /home/user/audio/002.wav注意,Kaldi默认不直接读取音频文件,而是通过wav.scp里的命令去读取,比如你可以写成:
utterance_id_001 sox /home/user/audio/001.wav -t wav - rate 16k |这样就能在读取的同时重采样。这个设计非常实用,因为Kaldi要求所有音频统一为16kHz采样率、16bit单声道的wav格式。我之前拿一批48kHz的录音直接跑,特征提取那一步各种报错,后来才意识到必须先转格式。
text文件是标注文本,格式是“发音编号 + 文本内容”,比如:
utterance_id_001 今天天气怎么样这三个文件(wav.scp、text、utt2spk)是最关键的,其他文件可以通过脚本生成。我在实际跑数据的时候发现,很多人忽略了utt2spk的重要性,如果你不提供这个文件,后面算说话人相关的特征(如CMVN)的时候就会出问题。
2.2 特征提取:MFCC和Fbank,为什么女声识别率比男声低
特征提取是ASR的第一步,也是网上热搜里“女声语音识别为什么比男声更低”这个问题的根源所在。
Kaldi最常用的特征有两种:MFCC(梅尔频率倒谱系数)和Fbank(滤波器组特征)。MFCC是经典特征,经过DCT去相关,适合GMM-HMM模型;Fbank保留了更多信息,适合DNN模型。
特征提取的命令长这样:
steps/make_mfcc.sh --nj 4 --cmd "run.pl" data/train exp/make_mfcc/train mfcc steps/compute_cmvn_stats.sh data/train exp/make_mfcc/train mfcc第一个命令生成MFCC特征,第二个命令计算倒谱均值方差归一化(CMVN)参数。CMVN这东西很重要,它能消除信道和说话人差异带来的影响,如果不做这步,识别率会明显下降。
至于女声识别率比男声低,这个问题挺有意思。本质上不是因为女声“更难识别”,而是特征的分布差异:女声的基频普遍比男声高,谐波间隔更大,导致在梅尔滤波器组(Mel Filterbank)的某个频段内,女声携带的判别性信息相对分散。更直接的原因是训练数据不均衡——很多开源数据集男声占比天然偏高,模型见过的男声更多,自然在男声上表现更好。解决方式通常是数据增强或者平衡训练集,Kaldi提供了speed perturbation和volume perturbation等数据增强工具:
# speed perturbation: 0.9x, 1.0x, 1.1x steps/data/augment_data_dir.sh --utt-suffix "sp" --ngroups 3 data/train data/train_sp这个操作非常简单直接,把每句话加速减速各生成一份,数据量立刻翻三倍。我跑了之后发现WER(词错误率)能下降差不多一个点,性价比很高。
2.3 声学模型:从GMM-HMM到DNN-HMM
Kaldi经典的声学模型训练路径是:单音素GMM(mono)→ 三音素GMM(tri1)→ LDA-MLLT(tri2)→ SAT(tri3)→ DNN-HMM(tri4或nnet3)。
一句话解释这几种模型的区别:
- 单音素模型:假设每个音素是独立的,不考虑上下文。这只是一个起点,用来做对齐(alignment),生成后续训练需要的帧级别标签。
- 三音素模型:考虑一个音素的前后文(context),比如“a”在“b_a_c”这样一个上下文里建模成独立的“triphone state”。这是GMM时代的主流方法。
- LDA-MLLT:对特征做线性判别分析和全局半绑定变换,相当于对特征空间做一次整体旋转,让类别更好分。
- SAT:说话人自适应训练,为每个说话人估计一个变换矩阵,把不同说话人的特征“掰”到同一个空间。
- DNN-HMM:用深度神经网络替换GMM来建模观测概率,输入通常是拼接了前后文的Fbank特征。
整个训练链路是一环扣一环的,前一个模型的输出(alignments)是后一个模型的输入标注。所以你不能跳步,必须从mono开始老老实实跑。这个过程我第一次跑的时候觉得简直是浪费时间,后来才明白,这叫“课程学习”(curriculum learning),先简单后复杂,每一步都在为下一步提供更好的对齐质量。
2.4 WFST解码图:语言模型如何参与识别
解码图是Kaldi里最难理解的部分,没有之一。简单说,解码图就是把声学模型、词典和语言模型三者编译成一张大的加权有限状态转换器(WFST),解码时在上面做搜索,找到最可能的词序列。
Kaldi里构建解码图的标准命令:
utils/format_lm.sh data/lang data/local/lm/threegram.arpa.gz data/local/dict/lexicon.txt data/lang_test理一下流程就是:文本 → 语言模型(n-gram)→ G.fst;词典 → L.fst;音素上下文 → C.fst;HMM状态 → H.fst。最后把HCLG四张图复合在一起,得到一张大图。
有个经典的眼花缭乱的命令:
steps/decode.sh --nj 4 --cmd "run.pl" exp/tri3/graph data/test exp/tri3/decode_test这个decode.sh会加载exp/tri3/graph里的HCLG.fst,对测试集的每个音频做解码,输出的就是识别结果。
一开始我完全不理解为什么解码图要搞这么复杂,后来总算悟了:WFST把所有约束(音素序列合法、单词拼写合法、词序列符合语言模型概率)统一成了一个带权重的图搜索问题,这样解码的时候不用运行时逐个检查约束,全部预编译好,解码速度才能快。这是Kaldi能在CPU上做到实时率(RTF)小于1的关键。
3. 从零跑通一个中文识别系统:完整实操记录
光讲概念没有用,我带你把一个真实的中文普通话识别系统完整跑一遍。这里我用的是开源的thchs30数据集,是清华发布的中文30小时语音库,体量小、适合学习,把整个流程跑通大概需要半天到一天的时间。
3.1 第一步:编译Kaldi
Kaldi的安装在它的GitHub仓库里有文档,但有几个前置依赖是文档里写得不清楚的。我用的环境是Ubuntu 22.04,依赖检查这一步尤其重要:
sudo apt-get update sudo apt-get install -y git make automake autoconf libtool g++ zlib1g-dev \ liblapack-dev libblas-dev libfst-dev python3 python3-pip sox重点说两个坑:
一个是libfst-dev。这是OpenFst库,Kaldi依赖它来做WFST操作。Ubuntu 22.04的软件源里带了OpenFst 1.7.x版本,跟Kaldi的兼容性还可以,但如果你用的是其他发行版或者自己编译的OpenFst,版本不一致会导致编译到latbin或fstbin相关工具时报错。另一个是BLAS/LAPACK库,Kaldi既支持用系统自带的ATLAS,也支持用OpenBLAS,建议直接装OpenBLAS,训练速度会快一些。
装完依赖之后,Kaldi的编译就相对顺利了:
git clone https://github.com/kaldi-asr/kaldi.git cd kaldi/tools make -j8 cd ../src ./configure --shared make depend -j8 make -j8./configure --shared这步建议加上--shared参数,这样生成的库是动态链接的,后续编译自己的工具时不用反复重新编译Kaldi源码。编译时间取决于机器配置,8核机器大概半小时左右。编译完成后验证一下能否进入src/bin下运行某个工具,比如:
./src/bin/compute-mfcc-feats --help能打印帮助信息就说明编译成功了。
3.2 第二步:下载thchs30数据并准备Kaldi格式
thchs30的官方下载地址在openslr.org上,也可以用Kaldi仓库里自带的脚本下载:
cd egs/thchs30/s5 local/download_and_untar.sh <your_path> https://www.openslr.org/resources/18 data_thchs30.tgz下载完之后,把数据软链接到thchs30的s5目录下:
ln -s /path/to/data_thchs30 data_thchs30 ln -s /path/to/resource data/resource然后跑数据准备脚本:
local/thchs-30_data_prep.sh /path/to/data_thchs30 /path/to/data/resource这个脚本会生成data/local/data目录,里面是整理好的wav列表、文本等中间文件,然后会自动构造出data/train、data/dev、data/test三个Kaldi数据目录。你可以用utils/validate_data_dir.sh来验证数据目录格式是否正确:
utils/validate_data_dir.sh --no-feats data/train注意现在还没有特征文件,所以要用--no-feats参数跳过特征验证。如果脚本输出#Data validation succeeded,恭喜,数据准备好了。
3.3 第三步:训练单音素模型和构建解码图
这是第一个核心训练步骤:
steps/train_mono.sh --nj 8 --cmd "run.pl" data/train data/lang exp/mono参数解释一下:
--nj 8:并行任务数。建议不超过CPU核数,太大反而会因为进程切换降低效率。--cmd "run.pl":本地并行。如果是在集群上跑,可以改成"queue.pl",但我们本地学习就老老实实用run.pl。data/train:训练数据目录。data/lang:语言目录,包含词典、音素映射等,是准备数据的时候自动生成的。exp/mono:输出目录。
单音素模型训练完后,需要先构建解码图,然后才能解码和评估:
# 构建基于单音素模型的语言模型解码图 utils/mkgraph.sh data/lang_test exp/mono exp/mono/graph # 解码测试集 steps/decode.sh --nj 8 --cmd "run.pl" exp/mono/graph data/test exp/mono/decode_test解码完之后,评估词错误率:
grep WER exp/mono/decode_test/wer_*thchs30在单音素模型下的WER大概在60%左右,不要被这个吓到,这只是baseline。我记得我第一次看到这个数字时怀疑自己装了假的Kaldi,后来才知道单音素模型本来就不强,只是用来做对齐的。继续往下训练,数字会一路降下来。
3.4 第四步:三音素模型、LDA-MLLT和SAT
接下来按部就班跑三音素模型:
# 基于mono对齐做三音素训练 steps/align_si.sh --nj 8 --cmd "run.pl" data/train data/lang exp/mono exp/mono_ali steps/train_deltas.sh --cmd "run.pl" 2500 20000 data/train data/lang exp/mono_ali exp/tri12500 20000分别对应三音素模型的决策树叶子节点数和GMM总高斯数。这个参数选择会直接影响模型容量和训练速度。thchs30数据量小,2500个叶子节点、2万高斯已经够用;数据量大时通常按经验公式缩放,比如每小时的语音约100个叶子节点。
然后继续LDA-MLLT和SAT:
# tri2: LDA-MLLT steps/align_si.sh --nj 8 --cmd "run.pl" data/train data/lang exp/tri1 exp/tri1_ali steps/train_lda_mllt.sh --cmd "run.pl" 2500 20000 data/train data/lang exp/tri1_ali exp/tri2 # tri3: SAT steps/align_fmllr.sh --nj 8 --cmd "run.pl" data/train data/lang exp/tri2 exp/tri2_ali steps/train_sat.sh --cmd "run.pl" 2500 20000 data/train data/lang exp/tri2_ali exp/tri3每一轮训练完都要重新对齐和解码,因为模型在变好,新模型能给出更好的对齐结果,而更好的对齐结果又能用于训练新模型。这个“训练-对齐-再训练-再对齐”的迭代过程,是Kaldi的经典套路。
跑完之后,同样用utils/mkgraph.sh和steps/decode.sh做解码评估。到tri3(SAT)阶段,thchs30测试集的WER应该能降到25%左右。
3.5 第五步:引入DNN-HMM
如果是四五年以前,接下来就是跑nnet2或者nnet3的DNN训练了。现在Kaldi官方主推的是nnet3 + chain模型,训练脚本是steps/nnet3/chain/train.py。但chain模型的配置脚本比较复杂,涉及biphone(双音素)语言模型和不同的特征输入,初学者容易被绕晕。
这里我建议先跑简单的nnet3 DNN,不涉及chain那一套复杂的配置:
# 先用tri3的对齐做DNN训练的数据准备 steps/align_fmllr.sh --nj 8 --cmd "run.pl" data/train data/lang exp/tri3 exp/tri3_ali # 用train_dnn.sh训练(这是nnet3中经典的脚本) steps/nnet3/train_dnn.sh --cmd "run.pl" --num-epochs 3 \ --hidden-layers 4 --hidden-dim 1024 \ data/train data/lang exp/tri3_ali exp/tri3_ali exp/tri4_dnn简单说一下参数:--hidden-layers 4 --hidden-dim 1024意味着4个隐藏层、每层1024个节点。对于30小时的数据,这个规模不能算大,但跑起来也不慢。--num-epochs 3表示训练3轮,这个值可以根据效果调整。
如果你想看看训练过程中损失的变化,可以看exp/tri4_dnn/log/train_log这个文件。正常情况下,每轮结束后损失应该下降,如果第二轮到第三轮没有明显变化,说明模型已经收敛,可以停止训练。
DNN解码和GMM时代有个显著差异:DNN模型的输入特征一般用的是40维fbank,而不是39维MFCC,所以训练完DNN之后要重新提特征。我们需要走一步特殊的解码流程:
# 为DNN提fbank特征 steps/make_fbank.sh --nj 8 --cmd "run.pl" data/train exp/make_fbank/train fbank steps/compute_cmvn_stats.sh data/train exp/make_fbank/train fbank然后解码时用--nj 8指定并行数,最后同样grep WER看结果。
跑到这一步,thchs30的WER能到20%左右,如果加上speed perturbation等数据增强,能进一步降到16%左右。对于一个30小时的小数据集,这个结果还算可以接受。
3.6 部署延伸:怎么把Kaldi模型用到实际产品里
跑完模型之后,自然要考虑部署问题。Kaldi原生训练出来的模型格式是final.mdl,这个格式没办法直接在Android、iOS或者嵌入式设备上跑。实际部署方案主要有三种:
第一种是Kaldi自带的在线解码服务,比如online2-wav-nnet3-latgen-faster这个工具,可以把模型跑在服务器上,支持流式识别。大家搜的“asr语音转文字有ubuntu的sdk包吗”,本质就是这种方案——Kaldi在Ubuntu服务器上部署成服务,对外提供接口,自己做SDK封装。
第二种是Kaldi到端侧的转换。比如把final.mdl转成OpenFst格栅再转成其他格式,或者用kaldi-native-fbank这类库在端侧做特征提娶然后把模型导出成ONNX或TensorFlow Lite格式。我曾试过把Kaldi的DNN模型导出到端侧跑,用的是kaldi2onnx这类工具,最后在树莓派上跑起来是OK的。最近大家讨论“llamacpp 部署asr”,其实也是这个方向——把ASR模型做端侧/本地化部署,绕开云端。
第三种是换引擎跑Kaldi模型。有一些开源项目可以直接加载Kaldi模型做解码,比如Vosk就是基于Kaldi的轻量级语音识别工具包,它把Kaldi模型封装成了友好的Python/Android/iOS API。如果你在嵌入式设备(比如ESP32)上做语音识别,Vosk有专门的轻量模型,或者直接接入讯飞这类SDK。从Kaldi学到的东西在迁移到这些引擎时是通用的。
4. 那些年我踩过的坑:Kaldi运行中的典型问题
这可能是整个笔记里含金量最高的部分。网上Kaldi教程不少,但很少有人会把报错和排查过程写清楚。我把自己踩过的、以及身边朋友踩过的坑整理了一下。
4.1 编译期的坑
报错信息:configure: error: Python not found
原因:Kaldi编译时需要Python开发头文件,不只是Python解释器。解决方案:
sudo apt-get install python3-dev报错信息:g++: error: unrecognized command line option
原因:GCC版本太老或太新,Kaldi源码可能存在兼容性问题。Ubuntu 22.04自带的GCC 11是可以正常编译的,如果你用了更新的GCC 13,某些第三方库可能不支持。解决方案是换用系统默认版本:
sudo apt-get install g++-11 cd src && make clean CXX=g++-11 make -j84.2 数据准备期的坑
报错信息:ERROR: Could not find file data/train/text
原因:脚本在跑数据准备时没有生成text文件。大部分情况是你的音频文件名和标注文本中的ID对不上。排查方法:
utils/validate_data_dir.sh data/train如果提示找不到text,先看看data/local/data里有没有文本文件,再确认下local/thchs-30_data_prep.sh里的路径是否指向正确。
报错信息:ERROR (compute-mfcc-feats): Read error on MFCC file
原因:音频文件解码失败,可能采样率不是16kHz,或格式不是wav。排查:用soxi命令看一下音频信息:
soxi audio/001.wav采样率不是16000的话需要重采样。Kaldi在读取wav.scp时如果指定了sox命令,可以在读取时重采样,我之前用过一个写法:
utterance_id_001 sox /path/to/001.wav -t wav - rate 16000 |注意最后那个竖线,表示输出到标准输出,Kaldi会从管道读取。
4.3 训练期的坑
报错信息:ERROR: Could not get lattice from latgen-faster-mapped
原因:解码时模型类型和输入特征维度不匹配。最常见的是用fbank特征去decode一个GMM模型,而GMM模型用的是MFCC特征。解决方案:务必为每个模型使用对应的特征。
报错信息:No space left on device
原因:Kaldi训练过程中会产生大量中间文件,尤其是对齐和解码的lattice数据。建议提前准备足够空间(thchs30全流程至少需要20GB),或者训练过程中定期清理exp/*/decode_*目录里的lattice文件。
报错信息:Not enough memory
原因:并行任务数(--nj)开得太大,或者模型参数设置太大。解决:减少--nj,同时检查模型参数量。我自己的经验是,thchs30不建议超过8个并行任务。
4.4 常见问题速查表
| 问题 | 可能原因 | 解决方法 |
|---|---|---|
| 编译报错缺少zlib | 未安装zlib开发包 | sudo apt-get install zlib1g-dev |
| run.pl任务失败 | 脚本路径不对 | 检查是否source了path.sh |
| 解码WER过高 | 模型未对齐或特征不匹配 | 按流程重新对齐,检查特征类型 |
| CMVN文件丢失 | 未执行compute_cmvn_stats.sh | 重新生成CMVN统计文件 |
| 数据目录验证失败 | 文件格式错误或ID不一致 | 用utils/validate_data_dir.sh定位问题 |
| GPU训练很慢 | batch size太小 | 增大--num-jobs-final或提升--minibatch-size |
我最想提醒的就是:一定要source path.sh。Kaldi的脚本全部依赖path.sh设置PATH环境变量,如果你不source,脚本会找不到Kaldi的工具,报各种莫名其妙的错误。进入egs/thchs30/s5目录后的第一件事就是:
source path.sh如果重启终端后忘了source,那后面所有脚本都会用系统里残留的旧工具,产生的结果不可复现。最好的习惯是把source写进.bashrc里,或者在每次跑实验前强制检查一下which run.pl的路径是否指向Kaldi目录。
5. 一个旧框架为何还能有生命力
踩完了坑,回头说点掏心窝的话。
Kaldi的学习曲线确实陡峭,文档也不友好,但它教会我的那些东西——WFST解码原理、GMM/DNN声学模型的演进逻辑、特征工程对识别效果的影响——在任何一个现代ASR工具里都能复用。正是因为它“框架感”强而不只是“API友好”,逼着你把每一层原理都搞明白,之后再去用Whisper或者Vosk,你才看得懂它们内部在做什么。
从时间投入来看,完整跑通thchs30这个流程,一个有一定Linux基础的人大概需要一周。这周的时间花得非常值,因为你会发现以后再接触任何ASR任务,心里都有一张完整的地图:数据怎么处理、特征怎么提、模型怎么训、解码怎么布。
当然我也要说,Kaldi毕竟发展了十多年,代码风格老旧、模块之间耦合度高、部分文档过时,新项目想直接用它搭生产系统确实要慎重。但作为一个学习工具、一个理解ASR原理的入口,它依然是最好的选择之一。
如果你还在犹豫要不要学Kaldi,我的建议是:先去把thchs30跑通一遍,不用追求多低的WER,跑通流程本身就是最大的收获。跑完之后你会发现,语音识别没有想象中那么神秘,它就是一环扣一环的特征处理、模型训练和搜索解码,每一个环节你都能动手控制——而这种“控制感”,恰恰是使用在线SDK永远体会不到的。