简介:一套面向课程设计及期末大作业的语音识别Python源码项目,基于深度学习方法实现,适合人工智能、计算机科学、电子信息等专业在校学生借鉴,也方便初学者快速接触语音识别。压缩包共13个文件,整体仅1.29MB,文件类型涵盖5个XML配置(工程结构)、3个Python源码(TFDataUtils.py负责数据预处理、CRNNCTC.py构建CRNN+CTC模型、CustomLayers.py实现自定义层)、2个pyc缓存文件,以及MD说明、JPG模型结构示意图和IML工程模块文件等,便于在IDE中打开并快速还原开发环境。项目以CRNN+CTC为技术路线,覆盖数据预处理、模型构建和训练等主要环节,代码经过测试运行成功,结构清晰、可扩展。已有743人在CSDN学习下载该资源,适合在此基础上二次开发,也可用于期末大作业展示或毕业设计初期演示。配套说明文档对运行方式和项目结构做了梳理,JPG示意图有助于理解模型整体结构,可帮助读者掌握语音识别项目中数据组织与模型搭建的实际思路。
1. 这个课设题目的分量:不是把音频丢进模型就完事
打开课程设计题目一看是“基于深度学习的语音识别python源码”,很多人的第一反应是去 GitHub 上找一个能跑的仓库,交差了事。但到了答辩现场,老师问一句“你的特征是怎么提取的”“CTC 原理是什么”“为什么用这个模型结构”,答不上来,分数就悬了。语音识别课设真正要的东西不是“能转文字”,而是从音频文件到最终文本之间那条完整、可解释、可复现的技术链路。
这条链路里有四个硬骨头:数据准备、特征提取、模型选择、解码策略。前两个决定模型能不能收敛,第三个决定收敛的上限,第四个决定最终效果和跑通之后的“像不像产品”。这篇笔记按我自己做课设和带项目时用的路径来展开,给你一套可以直接照着搭的方案,也把容易翻车的细节摊开讲。适合课程设计、毕业设计里需要快速跑通并讲清楚原理的读者,也适合刚入门想系统走一遍语音识别流程的从业者。
2. 数据先行:语料怎么找、怎么整成训练要的样子
2.1 选数据集:先看场景,再看规模
语音识别课设的数据集选择,第一原则不是“越大越好”,而是“和你的任务匹配”。如果你的场景是中文普通话,常见的选择是 THCHS-30 或 AISHELL-1;如果是英文,LibriSpeech 是稳妥基线。这些数据集在学术社区里用得广,资料多,遇到问题容易搜到解法,不要一上来就自己录语料——自采数据的标注成本远超训练成本。
数据集的规模决定你的模型选型和训练时间。课设时间一般按周算,不建议直接上 1000 小时的语料;选取子集是常见操作。我一般会建一个 manifest 文件管理数据,每行一个样本:
wav_path transcripttranscript 是纯文本标注,空格分隔词或拼音。这个 manifest 结构简单,后续写 Dataset 类时直接读文本就行,不用跟目录树绑死。
2.2 预处理管线:把音频统一到一条“标准线”
深度学习模型吃的是固定维度输入,但原始音频的采样率、声道数、时长五花八门。常见的统一方案是:全部转成 16kHz 单声道 PCM WAV。16kHz 覆盖语音主要频带(奈奎斯特频率 8kHz 对说话声足够),单声道是为了避免多声道混合带来的对齐问题。
ffmpeg -i input.mp3 -ac 1 -ar 16000 -f wav output.wav这条命令在有 ffmpeg 的机器上直接可用。-ac 1 强制单声道,-ar 16000 重采样到 16kHz,-f wav 确保输出是无压缩 PCM。注意:重采样不是简单抽点,ffmpeg 内部会做抗混叠滤波,所以不要自己用 Python 写重采样逻辑,直接用这个命令行工具更稳。
处理完音频后还需要检查每一条样本的长度分布。CTC 训练对过短的音频很不友好——一个“嗯”字可能只有 0.2 秒,对应特征帧只有 20 帧左右,模型很难从中学会映射。我一般会把短于 0.3 秒的样本直接过滤掉,并统计最长音频来设定 batch 打包时的 max_tokens 上限。
2.3 训练集/验证集切分:别让同一个人同时出现在两边
这是课设里最容易被忽略的一步。如果同一个说话人的录音既在训练集又在验证集,模型等于提前看过答案,验证集 loss 会很漂亮但不代表泛化能力。按说话人 ID 切分是业内惯例:把说话人列表先 shuffle,再按比例分给训练集和验证集。
speakers = list(set(meta['speaker_id'])) random.shuffle(speakers) train_spk = speakers[:int(len(speakers)*0.9)] val_spk = speakers[int(len(speakers)*0.9):]这段逻辑很简单:先拿到所有说话人 ID,shuffle 后按 9:1 切分,再根据说话人 ID 归属去分配音频文件。千万不要在文件级别直接 shuf 然后按行数切,那样同一个人的数据会同时混进两个集合。一个合理的数据准备阶段,应该在你开始写模型代码之前就完成,因为你后面所有调试都在跟这份切分结果打交道。
3. 建特征开口:从波形到 MFCC,模型看到的“语言”长什么样
3.1 为什么不能用波形直接喂网络
原始波形采样点只有时间信息和振幅,没有频率结构,直接丢给卷积网络相当于让模型从零学傅里叶变换。语音识别的通用做法是先算声学特征,最常见的就是 MFCC(梅尔频率倒谱系数)。MFCC 模拟人耳对频率的非线性感知,把 16kHz 音频压缩成几十维向量,同时保留发音器官的共振峰信息。
MFCC 的提取过程可以一句话概括:预加重 → 分帧 → 加窗 → FFT → 梅尔滤波器组 → 取对数 → DCT。实践中不需要手写每一步,librosa 库封装了完整链路:
import librosa def extract_mfcc(wav_path, n_mfcc=40, max_len=800): y, sr = librosa.load(wav_path, sr=16000) mfcc = librosa.feature.mfcc( y=y, sr=sr, n_mfcc=n_mfcc, n_fft=512, hop_length=160, n_mels=80 ) mfcc = mfcc.T # (time, n_mfcc) if mfcc.shape[0] < max_len: pad = np.zeros((max_len - mfcc.shape[0], n_mfcc)) mfcc = np.vstack([mfcc, pad]) else: mfcc = mfcc[:max_len] return mfcc.astype(np.float32)关键参数说明:
n_mfcc=40:保留前 40 维倒谱系数。取前 13 维是经典 HMM 时代的做法,深度模型时代普遍取 40 维,多出来的维度包含更多声学细节。hop_length=160:64ms 的窗口移动距离,对应 10ms 一帧。这个值直接影响时间分辨率,取太小帧数爆炸,取太大音频里辅音细节丢失。max_len=800:8 秒的帧数上限。超过的部分直接截断,不足的部分补零。截断对语音识别效果有影响,但课程设计场景通常每句话都在 5 秒内,截断边界可以接受。
3.2 标签对齐:为什么这里必须用 CTC
语音帧的数量和文本长度没有一一对应关系。一个 3 秒的音频产生约 300 帧 MFCC 特征,但对应的文字可能只有 4 个字。训练时需要一个损失函数来容忍这种“帧多字少”的错位,CTC(Connectionist Temporal Classification)就是为这个问题设计的。
CTC 不要求逐帧标注,它在所有可能的时间对齐路径上做求和,只要存在一条路径能映射到正确文本就算对。实现上,PyTorch 自带torch.nn.CTCLoss,输入是模型输出的 logits 序列(T, N, C),T 是帧数,N 是 batch 大小,C 是字符表大小加一个空白符。训练时要把文本转成整数索引序列传进去:
import torch def collate_fn(batch): mfccs, texts, text_lens = [], [], [] for mfcc, text in batch: mfccs.append(torch.FloatTensor(mfcc)) ids = [char2idx[ch] for ch in text] texts.append(torch.LongTensor(ids)) text_lens.append(len(ids)) mfcc_lens = torch.LongTensor([m.shape[0] for m in mfccs]) mfccs = torch.nn.utils.rnn.pad_sequence(mfccs, batch_first=True) texts_padded = torch.nn.utils.rnn.pad_sequence(texts, batch_first=True) return mfccs, texts_padded, mfcc_lens, torch.LongTensor(text_lens)这个 collate_fn 的作用是把一个 batch 里长度不均的 MFCC 和文本分别补齐。pad_sequence默认右侧补零,RNN 部分需要记录每个样本的真实长度传给torch.nn.utils.rnn.pack_padded_sequence,CTC loss 部分只需要帧数和文本长度。我在项目里见过不少人把长度信息丢掉,导致训练报错或 loss 停滞,这里单独列出来提醒一下。
数据准备全部完成后,建议把处理结果直接存成 numpy 文件或内存映射文件,避免每次训练都重新算 MFCC:
np.save('train/' + file_id + '.npy', mfcc)MFCC 提取是纯 CPU 计算,模型的每一次训练 epoch 都需要过一遍所有数据,如果每次从头算特征,大部分时间都耗在 IO 和计算上,不值得。
4. 模型结构怎么选:从轻量 CNN 到带注意力机制的 CRNN
4.1 三个主流的声学模型结构对比
语音识别的声学模型演进脉络清晰:CNN 适合提取局部频域特征,RNN 适合建模时序依赖,Attention 适合做序列到序列的对齐。课程设计不需要追最新的端到端大模型,选一个能讲清楚原理、训练资源可控的结构才是正解。
表格里做对比:
| 模型结构 | 参数量级 | 识别精度基线 | 训练难度 | 课设推荐度 |
|---|---|---|---|---|
| 纯 CNN(类 VGG) | 小 | 较低 | 低 | 适合快速跑通流程 |
| CNN + BiGRU + CTC | 中 | 中高 | 中 | 最推荐,精度与训练成本平衡 |
| Transformer + CTC/AED | 大 | 高 | 高 | 不推荐课设自己从头训 |
我自己做课设积累下来的经验是:前两层卷积负责把 40 维 MFCC 的局部频率模式压成更高层抽象,双向 GRU 负责把时间维度的上下文信息串起来,最后接一个线性层输出每个帧在所有字符上的概率分布。这套结构不挑显卡,GTX 1660 这种级别的卡也能训练,batch size 8 到 16 之间表现稳定。
4.2 完整模型定义:CNN-BiGRU 声学模型与训练循环
下面是我常用的一个课设友好型模型定义,代码量控制在 100 行左右,答辩时每一层的作用都能讲清楚:
import torch.nn as nn class CRNNForASR(nn.Module): def __init__(self, n_features, n_classes, hidden_size=256, num_layers=2): super().__init__() # 卷积段:把时间-频率特征转成高层局部特征 self.conv = nn.Sequential( nn.Conv2d(1, 32, kernel_size=3, padding=1), nn.ReLU(), nn.BatchNorm2d(32), nn.Conv2d(32, 32, kernel_size=3, padding=1), nn.ReLU(), nn.BatchNorm2d(32), nn.MaxPool2d(kernel_size=(2, 2)), # 时间维减半 nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.ReLU(), nn.BatchNorm2d(64), nn.MaxPool2d(kernel_size=(2, 2)) ) # 循环段:建模时序依赖 self.gru = nn.GRU( input_size=64 * (n_features // 4), hidden_size=hidden_size, num_layers=num_layers, batch_first=True, bidirectional=True, dropout=0.3 ) # 输出层:T*N*C,C为字符数+1(CTC blank) self.fc = nn.Linear(hidden_size * 2, n_classes) def forward(self, x, x_lens): x = x.unsqueeze(1) # (B, 1, T, F) x = self.conv(x) b, c, t, f = x.size() x = x.permute(0, 2, 1, 3).contiguous().view(b, t, c * f) x = nn.utils.rnn.pack_padded_sequence(x, x_lens.cpu(), batch_first=True, enforce_sorted=False) x, _ = self.gru(x) x, _ = nn.utils.rnn.pad_packed_sequence(x, batch_first=True) x = self.fc(x) return x关键逻辑说明:
- 卷积层输入是 (B, 1, T, F),这里
unsqueeze(1)加的维度是图像频道维度。两层卷积加两个 MaxPool,时间维度会被压缩 4 倍,所以后续传给 GRU 的x_lens要对应调整,这个细节很容易踩坑。 pack_padded_sequence是 RNN 训练的标准操作,传进去的长度必须是按帧数降序排列的,enforce_sorted=False让 PyTorch 自己排序并返回排序索引,省去手动处理的麻烦。- 双向 GRU 的隐藏层输出维度是 hidden_size 的两倍,所以
fc的输入维度是 512。 dropout=0.3是训练稳定性的关键。课设数据量不大,很容易过拟合,这个比例比较稳妥。
训练循环的核心几行:
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) loss_fn = nn.CTCLoss(blank=0, zero_infinity=True) for epoch in range(40): for batch in train_loader: mfccs, texts, mfcc_lens, text_lens = batch logits = model(mfccs, mfcc_lens // 4) # 时长因池化减半 log_probs = logits.log_softmax(dim=2).permute(1, 0, 2) loss = loss_fn(log_probs, texts, mfcc_lens // 4, text_lens) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step()mfcc_lens // 4是卷积池化后的帧数,记得一定要在传入模型和 loss 时同步调整,否则 shape 对不上直接报错。clip_grad_norm_是一根保险丝,RNN 训练时梯度爆炸很常见,设个 5.0 的阈值就能避免大部分翻车。
4.3 训练超参数:那些决定成败的默认值
训练轮次设定在 40 epoch 左右,配合早停(Early Stopping)看验证集 loss 连续 5 个 epoch 不降就停。batch size 8 到 16,取决于你的显存大小。学习率 1e-3 是 Adam 的稳妥起点,但如果训练初期 loss 不降反升,先把学习率降到 3e-4 再说。
字符表要注意包含一个空格字符,文本里的空格和语音中的静音不是一回事,但训练时会把空格当一个普通字符建模,这里有个细节:CTC 的 blank 不是空格,blank 是网络输出中的一个额外类别,表示“这一帧没有有效音素”,不要混淆。
数据增强方面,SpecAugment 是最实用的手段。做法是在 MFCC 特征的时间维和频率维随机遮挡一段:
def spec_augment(mfcc, time_mask=10, freq_mask=4): t = mfcc.shape[0] t0 = random.randint(0, t - time_mask) mfcc[t0:t0+time_mask, :] = 0 f = mfcc.shape[1] f0 = random.randint(0, f - freq_mask) mfcc[:, f0:f0+freq_mask] = 0 return mfcc这个增强对鲁棒性的提升明显,尤其是当你计划用测试集里不含的噪声音频做demo的时候。频率掩蔽模拟的是传输信道的频率选择性衰减,时间掩蔽模拟的是瞬断,都符合真实场景。
5. 训练与解码避坑指南:课设里最容易翻车的六个细节
5.1 损失降到 0.2 了但解码全是乱码
现象:训练 loss 在 1 以下,但把模型预测结果打印出来,全是一串重复的字符或空白。
原因:模型没有学会“用 blank 分隔重复字符”的 CTC 对齐逻辑。CTC 允许连续重复的字符被合并,但要求模型必须输出足够多的 blank 来区分同一个字内的重复和两个字之间的重复。如果字符表里没有 blank(索引 0 当做普通字符用了),解码自然乱。
解决:检查字符表构建,索引 0 固定留给 blank,真实字符从索引 1 开始映射;同时确认n_classes是字符表长度 + 1。在训练第 0 个 epoch 结束后,打印一次未解码的 logits 序列里每个位置的 top-1 索引,看看有没有 blank 类别的输出。如果没有,说明模型在学“永远别输出 blank”这条路,解码器就无法利用 CTC 的合并机制。
5.2 验证 loss 降、CER 不降,模板样本永远输不出空格
现象:loss 持续下降,但计算出来的 CER(字符错误率)停在 0.5 上下不动,且错误全部来自空格缺失。
原因:空格在文本标注中是字符,但 MFCC 特征里空格和静音段的区分度很弱,模型倾向于把空格类别的概率分给相邻音素。本质上是类别不平衡问题——空格字符在训练文本中占比过高或过低都不行。
解决:在文本清洗阶段,把多个连续空格合并为一个,并统计空格字符占比。经验范围是 15%-25%,如果偏离太离谱,换一个数据集或重新整理标注。另外,解码阶段可以对空格类别的得分加一个偏置,常见做法是logits[:, :, space_idx] += 0.5,这在推理时不用重新训练就能显著降低缺失空格的错误。
5.3 训练时 loss 变成 NaN,重启后依然复现
现象:某个 epoch 开始 loss 突然变成 nan,降学习率后仍然出现。
原因:第一个嫌疑是 logits 里的数值过大导致 softmax 上溢出,第二个嫌疑是梯度范数爆炸把权重更新成无穷大。但最隐蔽的第三个诱因是输入 MFCC 里有 NaN 值——某个 wav 文件损坏,librosa 读出来的数组里有空洞。
解决:在数据加载器里做一次显式检查。不重要的话直接舍掉这条样本,这种逻辑虽然简单,但能挡住 90% 的 NaN 翻车:
if np.isnan(mfcc).any(): return None同时在训练循环里保留最近 10 个 batch 的 loss 值,一旦出现 inf 或 nan,立刻打印当前 batch 的文件路径。日志里带路径,你才能定位是数据问题还是数值问题。强烈建议在项目目录建一个 checkpoints 文件夹,每轮保存模型:
torch.save(model.state_dict(), f'checkpoints/epoch_{epoch}_loss_{loss:.4f}.pt')这是课设的后悔药,模型训到 40 轮后期如果出现验证集精度波动,随时可以回滚到之前的最优 checkpoint。
5.4 GPU 显存不够,batch size 降到 2 还溢出
现象:模型结构不大,12G 显存的卡居然 OOM。
原因:问题不在 batch size 上,而在序列不等长。一个 batch 里有 1 条 8 秒的音频和 7 条 2 秒的音频,pad_sequence会把整个 batch 补齐到 8 秒的长度,计算量是实际有效计算的 4 倍,显存被无效 padding 占满。
解决:按音频时长分桶(bucket),训练前把样本按帧数分成若干桶,同桶内才做 batch 采样。实现上,在 collate 前对每个样本分配 bucket,scheduler 每次从一个桶里取 batch。这个技巧不需要改任何网络结构,训练速度提升 2-3 倍,显存压力大幅缓解。
5.5 多卡训练怎么省事怎么来
现象:课设机器上有两张卡,但用DataParallel后 loss 不降反升。
原因:DataParallel存在同步开销,序列长度不均时各卡负载不均衡,反向传播的梯度步调不一致。语音识别模型本身较小,双卡几乎没有收益。
解决:显式的方案是把 batch size 翻倍、梯度累积两步,相当于同类效果,但代码逻辑简单得多:
accumulation_steps = 2 loss = loss / accumulation_steps loss.backward() if (step + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()单卡加梯度累积等效于大 batch 的稳定更新,对 RNN 类的模型往往效果更好。
5.6 解码阶段:贪心解码先行,Beam Search 兜底
现象:训练一切正常,但推理结果总有连续字符重复(如 DevTools 变 Dee vToolss)。
原因:贪心解码只在每个时刻选概率最大的字符,没有考虑路径合并机制。CTC 解码的正确做法是先合并重复字符,再去除 blank,但不同路径可能产生同样的文本。
解决:先用贪心解码(实现简单,5 行代码)确认模型能出字,再上 beam search 提升效果。贪心解码时注意先对空白符和重复字符做合并再返回文本:
def greedy_decode(logits, idx2char): pred_ids = logits.argmax(dim=-1).squeeze(0).tolist() prev = None result = [] for idx in pred_ids: if idx != 0 and idx != prev: # 0 为 blank result.append(idx2char[idx]) prev = idx return ''.join(result)这段代码的逻辑是:跳过 blank(索引 0),连续相同字符只保留第一个。它是最接近“写论文里说得清楚”的一种解码方式。先确认这个能跑通,再去研究前缀 beam search,不然排错时要同时面对对齐错误和解码错误两个变量。
6. 验证模型不是玄学:一个能复现的评测脚本和三个进阶方向
结构定好、训练跑起来之后,最容易被忽视的就是评测。很多课设的“模型效果不错”都是肉眼看了几个例子就说出来的,没有量化指标。
评测语音识别系统,最通用的指标是 CER(字符错误率)或 WER(词错误率)。CER 计算公式是编辑距离除以参考文本长度,jiwer这个库可以直接算:
from jiwer import cer ref = "今天天气不错" hyp = "今天天气不促" print(cer(ref, hyp)) # 0.2训练完成后,把验证集里每个样本解码结果收集起来,统一算 CER。注意:如果标注是全拼音方案,就用拼音的错误率;如果标注是汉字,就用汉字 CER,不要混用。解码时对每个样本都做一次贪心解码,统计平均 CER,一般能到 0.3 以内就算课设达标,0.2 以内能上台讲。
进阶方向值得在剩余的课程时间做两件事,也能写进答辩幻灯片。第一件事是完善解码器,从贪心解码换成前缀 beam search(正好符合当下对多语种语音识别、人声抑制+深度学习这些热词事件里常常提到的精度提升技术),能再降几个点 CER。第二件事是做一个 offline 的简单 demo 界面,用 PyTorch 加载训练好的模型,录音走麦克风 5 秒,输出文字。这一套流程走下来,你的课程设计就不仅仅是“跑通了源码”,而是一个可演示、可评测、可解释的项目。
我的个人习惯是每训练完一个 epoch 就手动跑一遍验证集的 20 条样本看解码输出。不要只盯着 loss 曲线,loss 降到 0.1 但解码全是单字重复的情况我遇到多次,这比 loss 不降更隐蔽。检查输出时重点看三点:有没有连续重复的字符、有没有大量 blank 被跳过、空格位置对不对。
语音识别课设做到这个程度,你已经比大多数用现成 API 完成的同学多走完了从数据到模型的完整链路。答辩时把模型结构图、CER 曲线、失败样本分析贴出来,自信讲就好。希望帮到你。
本文还有配套的精品资源,点击获取