首页 / 资讯中心 / 文章详情

CRNN+CTC端到端语音识别源码解析:从原理到实战

CRNN+CTC端到端语音识别源码解析:从原理到实战 ★ FEATURED ARTICLE
简介基于深度学习的语音识别课程设计源码包面向计算机相关专业在校学生、教师及企业开发者尤其适合作为课程作业、期末大作业或毕业设计的参考项目。包内提供可直接运行的完整工程核心基于CRNN与CTC的识别思路包含数据预处理脚本、自定义网络层、模型训练与推理主程序并配有结构示意图和说明文档便于理解整体流程与二次扩展。压缩包共13个文件包含3个Python源码、多个XML/IML工程配置文件、Markdown说明文件、一张网络结构图以及编译缓存文件整体仅1.29MB十分轻量易部署。当前已有743人学习下载代码经过运行测试适合有一定Python基础并希望快速上手深度学习语音识别项目的读者参考借鉴可在此基础上修改增加功能服务于课程设计、初期项目演示等场景。1. 课程设计里的语音识别这套 CRNNCTC 源码真正能跑通的东西如果你下载过几个深度学习语音识别的课程设计源码一定遇到过这种情况压缩包打开一堆文件却不知道该先运行哪个最后只能看 README 跑 Demo。这份基于深度学习的语音识别 python 源码不太一样它由 CRNNCTC.py、TFDataUtils.py、CustomLayers.py 三个核心文件加一份说明文档组成是一个典型的 CRNN CTC 端到端识别工程。输入是 wav 音频切片后的梅尔特征输出直接是汉字或字符序列绕开了传统语音识别里 HMM 和强制对齐那套前置知识。对于计科、人工智能、大数据方向的同学来说适合做期末大作业、课程设计或者毕设开题演示。基础薄弱的可以直接把它当黑匣子先跑通有点基础的同学则可以改字符表、换网络层做成自己的一套东西。2. 读懂 CRNNCTC.py 的模型骨架卷积特征提取与双向 GRU 的配合语音识别课程设计的核心难点不在于 Python 语法而在于“音频帧数”和“标签长度”是不对齐的。CRNNCTC.py 要解决的正是这个问题它把模型拆成卷积特征提取、循环神经网络建模、CTC 损失对齐三个部分。下面从原理讲起再落到这个文件里最常见的结构。2.1 从音频特征到稀疏张量CRNNCTC 为什么能端到端识别一段 1 秒的音频以 16kHz 采样按 10ms 帧移切出来约 100 帧而对应的文字可能只有 3 个汉字。传统 HMM 方案需要先做音素对齐把每一帧对应到某个音素上这个过程既依赖语言模型又依赖专家知识。CTC 解决这个问题的方式是引入一个空白符号允许某一帧输出空白也允许连续重复同一个字符最终在解码阶段把重复和空白合并得到原始标签序列。模型不需要知道字在哪一帧结束只需要在整体上最大化正确序列的概率这就是 CTC 的端到端思想。CRNN 里 CNN 负责从梅尔谱中提取局部声学模式并通过池化压缩时间分辨率RNN 用双向 GRU 学习上下文关系让前向和后向信息共同决定当前帧的输出。相比纯 RNNCRNN 的收敛速度更快相比纯 CNN它又能捕捉时序依赖。课程设计里常见的词表大小从几十到几百不等用这套结构训练一小段中文命令词识别效果和训练速度都很合适。2.2 CRNNCTC.py 关键结构拆解Conv2D、批归一化、双向 GRU 与 CTC loss以这类工程最常见的写法为例CRNNCTC.py 里的模型通常先是几层卷积每层后面跟着批归一化和最大池化。卷积核多选 3×3池化步长多选 2×2这意味着每经过一层池化时间维长度近似减半。下面这段代码是典型的 CRNN 前端import tensorflow as tf def build_crnn_frontend(inputs, num_classes, rnn_units128): # inputs 形状: [batch, time, freq, 1] net tf.keras.layers.Conv2D( filters32, kernel_size3, strides(1, 1), paddingsame, activationrelu, nameconv1)(inputs) net tf.keras.layers.BatchNormalization(namebn1)(net) net tf.keras.layers.MaxPooling2D( pool_size(2, 2), strides(2, 2), namepool1)(net) net tf.keras.layers.Conv2D( filters64, kernel_size3, strides(1, 1), paddingsame, activationrelu, nameconv2)(net) net tf.keras.layers.BatchNormalization(namebn2)(net) net tf.keras.layers.MaxPooling2D( pool_size(2, 2), strides(2, 2), namepool2)(net) # 把高和融合进通道只保留时间维 shape net.get_shape().as_list() net tf.reshape(net, [tf.shape(net)[0], shape[1], -1]) return net逻辑说明Conv2D 先提取局部声学特征BatchNormalization 让每一层输入分布稳定避免梯度消失和初始 loss 太大。两层 MaxPooling 把时间维压缩约 4 倍也就是说原始 100 帧音频进入 RNN 时大约只剩 25 个时间步。最后用 reshape 把频率维和通道维合并得到形状为[batch, time, merged_dim]的序列这样才能送给 GRU 层。参数说明filters32/64 是课程设计里最常见的小网络配置显存不够时可以减到 16/32。rnn_units128 控制双向 GRU 的隐层维度词表大或者句子长时可以调到 256。需要注意最后 reshape 时使用了shape[1]如果前面池化步长改动过这里的 time 维要跟着算清楚否则运行时报维度错误。双向 GRU 部分和 CTC loss 通常这样组织def build_rnn_and_loss(net, seq_len, sparse_labels, num_classes, rnn_units128): outputs, _ tf.compat.v1.nn.bidirectional_dynamic_rnn( cell_fwtf.compat.v1.nn.rnn_cell.GRUCell(rnn_units), cell_bwtf.compat.v1.nn.rnn_cell.GRUCell(rnn_units), inputsnet, sequence_lengthseq_len, dtypetf.float32 ) outputs tf.concat(outputs, axis-1) # [batch, time, 2*rnn_units] logits tf.keras.layers.Dense(num_classes)(outputs) # [batch, time, num_classes] logits tf.transpose(logits, [1, 0, 2]) # CTC 要求 time-major loss tf.nn.ctc_loss( labelssparse_labels, inputslogits, sequence_lengthseq_len ) return tf.reduce_mean(loss), logits逻辑说明bidirectional_dynamic_rnn里sequence_length必须传实际有效时间步不能全填最大值否则 GRU 对 padding 部分也会计算隐状态导致 CTC 对齐错乱。解码时同样要传真实的 seq_len。Dense(num_classes)把每个时间步映射成词表大小的 logits注意这里输出没有 softmaxCTC loss 内部会做 softmax 归一化。最后把 logits 转成[time, batch, num_classes]这是 TensorFlow 历史遗留的约定漏掉 transpose 会直接报 shape 错误。参数说明num_classes指的是字符表大小注意是否包含空白符。如果你的字符表只有 63 个字那么num_classes通常设 64其中 0 号索引留给空白或者单独指定blank_index。不同工程写法不同建议训练前打印一下sparse_labels的值确认空白索引没有被字符占用。2.3 训练数据流与 batch时序长度怎么对齐、标签怎么传CRNNCTC.py 训练时输入占位符一般是三个特征、有效长度、稀疏标签。特征和长度在 TFDataUtils.py 里已经生成训练循环里只需要组织 feed_dict。常见的占位符定义如下X tf.compat.v1.placeholder(tf.float32, [None, None, None, 1], nameX) seq_len tf.compat.v1.placeholder(tf.int32, [None], nameseq_len) sparse_labels tf.compat.v1.sparse_placeholder(tf.int32, namesparse_labels)逻辑说明X 的四个维度分别是 batch、time、frequency、channel。time 维是变长的所以设为 None每个 batch 内的样本会 pad 到同一长度。seq_len 存放每个样本压缩后的有效时间步这个值必须和模型实际输出时间步一致比如原始 100 帧经过两层 stride2 的池化后有效长度大约是 25。sparse_labels 是稀疏张量用于描述不同长度的标签序列它由 indices、values、dense_shape 三部分组成避免把标签 pad 成固定长度矩阵造成浪费。训练时通常这样迭代for epoch in range(epochs): for batch_x, batch_seq_len, batch_sparse_labels in epoch_iterator: _, cost_val sess.run( [train_op, cost], feed_dict{ X: batch_x, seq_len: batch_seq_len, sparse_labels: batch_sparse_labels } )逻辑说明epoch_iterator来自 TFDataUtils.py 的数据读取函数它会按 batch 切分特征和标签并把seq_len减半后传给模型。很多新手在这里出错以为 seq_len 是原始音频帧数实际上必须传入经过池化压缩后的长度。建议在模型构建时用一个函数统一计算压缩倍率不要手写死数字。3. TFDataUtils.py 与 CustomLayers.py把 wav 变成模型能吃的数据模型结构看懂之后下一步就是把音频转成符合输入的张量。TFDataUtils.py 负责特征提取和标签编码CustomLayers.py 负责兼容不同版本的 TensorFlow 层。这两块才是课程设计里真正容易被卡住的地方。3.1 从 wav 到梅尔谱预加重、分帧、加窗、FBank课程设计用的音频和语音助手不一样不需要太大的特征维度。常见做法是用 16kHz 采样率、单声道、40 维 FBank 特征。FBank 比 MFCC 少一步离散余弦变换神经网络自己能学会那部分去相关所以在深度学习语音识别里更常用。一个典型的特征提取流程如下import numpy as np import scipy.io.wavfile as wavfile def read_wav_to_signal(path, target_sr16000): sr, signal wavfile.read(path) if signal.ndim 1: signal signal.mean(axis1) # 多声道转单声道 if sr ! target_sr: # 重采样课程设计里可以直接线性插值或者用 librosa signal resample(signal, sr, target_sr) if signal.dtype ! np.float32: signal signal.astype(np.float32) / 32768.0 # int16 转 float return signal逻辑说明wavfile.read 会同时返回采样率和数值数组。很多录音软件采出来是双声道直接送入模型会得到错误 shape所以要先求平均转单声道。整型转浮点并归一化到 [-1, 1] 是为了与模型训练时数值分布一致如果不做这一步后面算 log 谱时会溢出。参数说明target_sr16000 是语音识别最常见的采样率。如果你的音频本身是 8kHz 电话录音建议先重采样到 16kHz否则特征和模型训练时的分布完全不匹配。resample函数可以自己写线性插值也可以直接librosa.load(path, sr16000, monoTrue)一步到位但注意 librosa 返回的已经是 float不需要再除以 32768。分帧和加窗的代码在 TFDataUtils.py 里通常长这样def get_fbank(signal, sample_rate16000, n_mel40): # 预加重 signal np.append(signal[0], signal[1:] - 0.97 * signal[:-1]) frame_len int(0.025 * sample_rate) # 25ms frame_step int(0.010 * sample_rate) # 10ms frames framing(signal, frame_len, frame_step) frames * np.hamming(frame_len) power_spec np.abs(np.fft.rfft(frames, n512)) ** 2 mel_filter make_mel_filter(sample_rate, n_fft512, n_meln_mel) fbank np.dot(power_spec, mel_filter.T) fbank np.log(fbank 1e-10) return fbank.astype(np.float32)逻辑说明预加重系数 0.97 用来提升高频分量因为语音在高频段能量衰减快。25ms 帧长、10ms 帧移是语音识别标准配置能兼顾频率分辨率和时间分辨率。加 Hamming 窗是为了减轻频谱泄漏。最后取 log 是为了压缩动态范围符合人耳对声音响度的感知。参数说明n_mel40是课程设计里性价比最高的配置进阶同学可以改成 80 或 128FBank 维数上升后模型训练会变慢但可能有小幅度提升。n_fft512在 16kHz 下频率分辨率约 31Hz足够覆盖 300Hz 到 8kHz 的语音主能量区。这里fbank的返回值形状是[time, n_mel]后续送入模型前还要加一维 channel。3.2 标签编码与稀疏张量CTC 的标签序列怎么转成 indicesFP 特征转换完成后标签也要从字符串转成整数序列。TFDataUtils.py 里通常会维护一个字符表比如“abcdefghijklmnopqrstuvwxyz_”然后给每个字符分配一个索引。生成稀疏张量的标准写法是def sparse_tuple_from(sequences, dtypenp.int32): indices [] values [] for batch_idx, seq in enumerate(sequences): for time_idx, token in enumerate(seq): indices.append([batch_idx, time_idx]) values.append(token) dense_shape [len(sequences), max(len(s) for s in sequences)] return (np.array(indices, dtypedtype), np.array(values, dtypedtype), np.array(dense_shape, dtypedtype))逻辑说明CTC 接收的 labels 是稀疏张量indices 里第一维是 batch 内第几条数据第二维是这条数据内部的第几个字符。values 就是字符对应的整数编号。dense_shape 表示如果强行用密集矩阵存储每个 batch 的宽度取该 batch 内最长标签长度。这样不同长度的标签可以放在同一个 batch 里而无需 padding 成完全一致的矩阵。参数说明dense_shape 的第二个值在不同实现里有差异。有些工程会固定设成最大标签长度有些会设成 batch 内动态最大长度两者都能训练但固定值会占用更多内存。如果你的ctc_loss一直报 label 长度超过输入时间步请检查 dense_shape 是否填得过大。3.3 CustomLayers 的封装TensorFlow 版本兼容与 Lambda 层避坑很多同学把源码下载下来后第一件事是跑pip install tensorflow结果装的是 TF 2.x然后 CRNNCTC.py 里tf.compat.v1.nn.bidirectional_dynamic_rnn还能用但 CustomLayers.py 里某些自定义层报错。这个问题非常普遍。CustomLayers.py 存在的意义就是把不同版本 TensorFlow 的差异隔离开来常见的做法是用tf.keras.layers.Layer封装一个双向 GRUclass BidirectionalGRU(tf.keras.layers.Layer): def __init__(self, units): super().__init__() self.gru_fw tf.keras.layers.GRU(units, return_sequencesTrue) self.gru_bw tf.keras.layers.GRU(units, return_sequencesTrue, go_backwardsTrue) def call(self, inputs, maskNone): out_fw self.gru_fw(inputs, maskmask) out_bw self.gru_bw(inputs, maskmask) out_bw tf.reverse(out_bw, axis[1]) return tf.concat([out_fw, out_bw], axis-1)逻辑说明用 Keras 的 GRU 层替代tf.nn.rnn_cell.GRUCell天然兼容 TF 2.x不需要compat.v1。go_backwardsTrue让反向 GRU 从序列尾部往头部读tf.reverse再把输出倒回正序这样正向和反向输出在时间轴上对齐最后 concat 成[batch, time, 2*units]。在 call 里传 mask 可以屏蔽 padding 部分对 GRU 状态的影响比手写 sequence_length 更省心。参数说明units 要和 CRNNCTC.py 里的 rnn_units 保持一致。这个层在 TF 2.x 下可以直接被model.compile调用但如果原工程还是 sess.run 式的训练循环则需要把 layer 的 call 结果接到占位符后面。CustomLayers.py 里的其它封装比如对输入做tf.transpose和tf.expand_dims的 Lambda 层大多也是为了解决 TF 版本间 API 改名的问题遇到报错时优先看这个文件里的注释和改动记录。4. 避坑课程设计跑起来之前必须检查的五个地方我拿到这套源码时第一件事不是看模型而是先列了一个排查清单。下面五条是我见过最多的翻车点按“现象、原因、解决”的方式写出来你对照检查能省下至少一个下午。4.1 现象import CustomLayers 报错提示没有 GRUCell 或 contrib很多同学在 TF 2.x 下运行报错信息是module tensorflow has no attribute nn或者tf.contrib不存在。原因是原工程很可能基于 TF 1.x 写的TF 2.0 移除了一大批高层 API。解决方式优先把 CRNNCTC.py 里所有tf.nn.rnn_cell.GRUCell改成tf.compat.v1.nn.rnn_cell.GRUCell同时把tf.nn.bidirectional_dynamic_rnn补齐compat.v1前缀。如果改动后发现 CustomLayers.py 仍然报错就直接用上面 3.3 里的 Keras 层替换整个双向 GRU 块避免继续和旧 API 纠缠。改完以后先跑一个 10 个 batch 的训练确认不再报错再全量训练。4.2 现象训练时 loss 不下降或者一开始就 NaN如果训练几千步 loss 始终在 5.0 以上不动大概率是特征和标签对不上。常见原因有两个一是音频被读成了双声道或非 16kHz特征分布和模型预期不一致二是标签稀疏张量的 dense_shape 填得比实际最大标签长度大太多导致 CTC 在计算时出现大量越界。NaN 通常来自 log 里遇到负数或零比如对全零的静音段做np.log或者学习率设成 0.01 以上。解决方式训练前打印一个 batch 的 X 和 labels确认 X 的数值范围大致在 [-5, 15] 之间labels 的最大值不超过 num_classes。把优化器换成 Adam学习率降到 1e-3 以下多数课程设计工程用 1e-3 起步是合理的但遇到 NaN 就立刻降一半。4.3 现象预测结果全是空白或者输出一堆重复字符模型训练后识别出来要么是空字符串要么是“你你你好好好”。CTC 解码时如果 blank 索引设置错会把所有字符当成空白丢掉如果解码后没有合并重复中文多字词会出现大量叠字。解决方式检查ctc_loss里的blank_index和字符表的对齐关系。常见约定有两种空白放在字符表末尾索引 num_classes-1或者放在索引 0。如果源码在 loss 里用了blank_index0那 TFDataUtils.py 生成的标签就绝对不能出现 0。另外使用tf.nn.ctc_greedy_decoder时它本身会合并重复和删除空白但如果你在这个基础上又手动合并一次就会把真正的叠词比如“谢谢”错误合并成“谢”所以先不加后处理直接看裸输出再判断问题出在哪一步。4.4 现象wav 读取报错或者音频长度和特征维度对不上有些录音是 44.1kHz 采样的音乐片段有些是 8kHz 电话语音直接送进为 16kHz 设计的模型特征帧数会差好几倍导致 feed_dict 时维度不一致。还有可能是 wav 本身是 int32 格式wavfile.read返回的 dtype 不是 int16除 32768 以后数值变得很奇怪。解决方式在 TFDataUtils.py 里加一个统一入口函数强制sr16000、monoTrue、np.float32。我一般会在读取后加两行断言一个是assert len(signal.shape) 1另一个是assert signal.dtype np.float32一旦数据没对齐立刻报错而不是等到训练中途才炸。4.5 现象GPU 显存不足或者 CPU 训练慢到怀疑人生课程设计源码通常没做过性能优化如果直接把所有音频 pad 到全局最长长度显存会被无用的 padding 占满。另一个原因是 batch_size 设置太大再叠加双向 GRU 和卷积层一张 4GB 显存的卡很容易 OOM。解决方式把 batch_size 从源码默认值减半比如 64 改成 32。同时按音频长度排序后分组让同一个 batch 内的样本时长相近减少 padding 比例。这个方法叫 bucketing不需要改模型代码只要在数据迭代器里按seq_len排个序就行。CPU 训练时建议把n_mel降到 40rnn_units降到 128能明显提升迭代速度。5. 换自己的录音验证单文件预测与批量小改造课程设计源码自带的 demo 音频跑通只是第一步真正答辩时要能拿你自己的录音识别出来。这一章教你加两个小脚本一个识别单文件一个批量识别目录下所有 wav最后还能输出简单的时间戳。5.1 单文件预测加载 checkpoint、对齐预处理、CTC 解码去重假设你已经训练出 checkpoint预测脚本的核心流程是读音频 → 转 FBank → 按模型要求调整维度 → 输入模型拿 logits → CTC 解码 → 索引转字符串。代码骨架如下import tensorflow as tf from TFDataUtils import read_wav_to_signal, get_fbank def predict_one(path, sess, X, seq_len, logits, char_list, blank_index): signal read_wav_to_signal(path) fbank get_fbank(signal) # [time, n_mel] x fbank[np.newaxis, :, :, np.newaxis].astype(np.float32) feed_len int(np.ceil(fbank.shape[0] / 4)) # 两层 pool 压缩 4 倍 logits_val, sess.run([logits], feed_dict{X: x, seq_len: [feed_len]}) logits_t tf.constant(logits_val.transpose(1, 0, 2)) # [time, batch, num_classes] decoded, _ tf.nn.ctc_greedy_decoder(logits_t, [feed_len]) # 返回 [batch, 未定长] 的稀疏张量取第一个样本 indices decoded[0].indices.numpy() values decoded[0].values.numpy() result [] for v in values: if v ! blank_index: result.append(char_list[v]) return .join(result)逻辑说明feed_len必须和模型输出时间步一致这里写死除以 4是因为模型里正好有两层 stride2 的池化。如果你的模型多加了一层池化这里要改成除以 8。logits_val.transpose(1, 0, 2)把网络输出的[batch, time, num_classes]转成[time, batch, num_classes]因为ctc_greedy_decoder要求 time-major。参数说明char_list是字符串列表索引和 TFDataUtils.py 里的字符表必须完全一致漏一个字符就会出现乱码。blank_index的值参考第 4 章排除法建议打印values里实际出现的索引再看哪个索引对应空白。解码结果通常已经去重删空但如果你发现还有重复可以在循环里加一个if v ! prev的简单判断。5.2 批量预测写一个 predict_batch.py 处理一个目录的 wav单文件调试通过后批量识别就很自然了。为了提高 GPU 利用率按音频长度排序后用动态 batch 处理import glob, numpy as np def predict_dir(wav_dir, sess, X, seq_len, logits, char_list, blank_index, batch_size8): paths glob.glob(f{wav_dir}/*.wav) features [(p, read_wav_to_signal(p)) for p in paths] features.sort(keylambda x: x[1].shape[0]) # 按原始帧数排 for start in range(0, len(features), batch_size): batch features[start:start batch_size] batch_x, batch_len [], [] for p, sig in batch: fb get_fbank(sig) batch_x.append(fb[np.newaxis, :, :, np.newaxis]) batch_len.append(int(np.ceil(fb.shape[0] / 4))) max_len max(fb.shape[1] for fb in batch_x) padded np.zeros([len(batch), max_len, batch_x[0].shape[2], 1], dtypenp.float32) for i, fb in enumerate(batch_x): padded[i, :fb.shape[1], :, :] fb[0] # feed_dict 和单文件一样这样循环里拿结果逻辑说明按长度排序后相邻样本的batch_len差别不大padding 产生的时间维浪费最少。max_len取当前 batch 内的最大值而不是全局最大值内存占用更稳。这里padded的第一维是 batch 数量第二维是当前 batch 最大 time 维。参数说明batch_size在 CPU 上建议 4~8GPU 上可以试 32 以上。如果遇到 OOM先减小 batch_size而不是把max_len改成全局最大后者反而更容易爆显存。批处理结果可以直接逐行写入result.txt文件名和识别文本用制表符分隔方便答辩时展示。5.3 把识别结果带上时间戳自己加一小段后处理课程设计如果只需要“识别对错”单文本输出就够了。但如果想做成演示系统每个字对应的时间位置会让效果专业很多。CTC 解码出的稀疏张量里indices的第二列就是每个字符在输出时间步上的位置乘上帧移和时间压缩倍数就能得到秒级时间点time_seconds decoded[0].indices[:, 1] * 0.01 * 4这里0.01是 10ms 帧移4是模型时间压缩倍数。比如某字对应索引 20那么它出现在第 20 × 0.04 ≈ 0.8 秒左右。如果想得到每个字持续多久可以拿后一个字符的开始时间减去当前字符的开始时间最后一个字的结束时间用音频总时长近似。做字幕或者划线展示都够用。我第一次拿这套课程设计代码跑自己的声音时十条有八条识别成乱码最后发现是麦克风录音默认 44.1kHz而源码假设 16kHz特征帧数全对不上。从那次之后我每次拿到语音识别的源码第一件事就是用一行代码打印所有待测 wav 的采样率和声道数再进训练和预测流程。这个习惯救了我后面好几次希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站