1. 为什么我们需要MFCC从声音到机器能懂的数字如果你玩过语音助手或者用过手机上的语音转文字功能你可能会好奇机器是怎么“听懂”人说话的。它听到的明明是一段连续起伏的声波和我们听到的“你好”完全不是一回事。这里面的关键一步就是把原始的声音信号转换成一串机器擅长处理的数字特征。MFCC就是干这个活的“金牌翻译官”。我刚开始接触语音识别的时候也觉得MFCC这个词挺唬人的又是“梅尔频率”又是“倒谱系数”听起来特别学术。但后来在项目里实际用起来才发现它的设计思路其实非常巧妙完全是模仿了我们人耳的听觉特性。简单来说它做的工作就是把声音中那些对人耳识别语音有用的信息提炼出来同时把没用的噪音信息过滤掉最后打包成一串紧凑的数字向量。这串向量就是后续语音识别模型比如深度神经网络的“标准口粮”。为什么非得是MFCC呢我试过直接用原始的声波数据我们称之为时域信号或者简单的频谱图丢给模型去训练效果往往很差。因为原始数据里包含了太多冗余信息比如你录音时的环境噪音、设备底噪甚至你说话时气息的强弱变化这些都会干扰模型学习真正有用的语音模式。MFCC就像一个有经验的厨师能把一条鱼原始语音去鳞、剔骨、切片只留下最鲜美的鱼肉语音特征这样食客识别模型吃起来才方便又美味。在语音识别、说话人识别、语音情感分析这些领域MFCC几乎是特征提取的标配。它那几十个系数通常是13个静态系数加上它们的一阶和二阶差分组成39维非常高效地刻画了一段语音的核心特性。接下来我就带你亲手“烹饪”这条鱼从处理原始声音开始一步步算出MFCC系数。我会用Python代码把每个步骤都实现出来你跟着做一遍就能彻底明白这个“金牌翻译官”是怎么工作的了。2. 处理声音的第一步预加重与分帧拿到一段语音信号比如一个.wav文件我们看到的是一长串数字代表了声音振幅随时间的变化。直接处理这一长串数据是不行的因为语音信号有一个特点它在很短的时间比如20-40毫秒内是相对稳定的但放长了看又是变化的。这就像一段视频每一帧画面几乎是静止的但连续播放就成了动画。所以我们的第一步就是把声音“剪”成一帧一帧的短片段来处理。2.1 给声音“提亮高音”预加重在分帧之前我们通常先做一个叫“预加重”的操作。这其实就是一个高通滤波器。为什么要这么做呢因为人在发声时声带和嘴唇会对高频部分的能量有天然的抑制导致信号的高频成分比低频成分弱。预加重的作用就是提升高频部分让整个频谱变得更平坦这样后续处理时所有频率成分都能得到相对均衡的对待有助于提高高频部分的分辨率。实现起来非常简单就是一个一阶差分操作。你可以把它理解成让当前时刻的信号值减去前一个时刻信号值的一部分。import numpy as np def preemphasis(signal, coeff0.97): 对语音信号进行预加重滤波。 参数: signal: 输入的原始语音信号一维数组。 coeff: 预加重系数通常在0.9到1.0之间常用0.95或0.97。 返回: 预加重后的信号。 # 第一个样本保持不变从第二个样本开始每个值减去前一个值的coeff倍 return np.append(signal[0], signal[1:] - coeff * signal[:-1]) # 示例读取一个wav文件并做预加重 import scipy.io.wavfile as wav # 假设你有一个名为‘hello.wav’的录音文件 sample_rate, original_signal wav.read(hello.wav) # 通常我们处理单声道如果是立体声取其中一个通道 if original_signal.ndim 1: original_signal original_signal[:, 0] emphasized_signal preemphasis(original_signal, coeff0.97)你可以把处理前后的信号画出来对比一下会发现波形在细节上尤其是快速变化的部分变得更尖锐了这就是高频能量被增强的效果。2.2 把声音切成小段分帧与加窗接下来就是分帧。假设我们的采样率是16000 Hz即一秒钟有16000个点。如果我们设定一帧的长度为25毫秒那么一帧就包含0.025 * 16000 400个采样点。帧与帧之间不是完全割裂的而是有重叠的通常帧移步长是10毫秒160个点。重叠是为了避免两帧之间变化太剧烈保证帧与帧之间的平滑过渡。光分帧还不够直接截断信号会带来频谱泄露的问题。想象一下一个完美的正弦波如果你在不是它周期整数倍的地方突然截断它的频谱就会变得不干净出现很多原本不存在的频率成分。为了解决这个问题我们需要给每一帧信号乘上一个“窗函数”让帧两端的信号平滑地衰减到零。最常用的就是汉明窗Hamming Window。def frame_signal(signal, sample_rate, frame_len0.025, frame_stride0.01): 将信号分帧并加汉明窗。 参数: signal: 预加重后的信号。 sample_rate: 音频采样率。 frame_len: 帧长单位秒。 frame_stride: 帧移步长单位秒。 返回: frames: 分帧并加窗后的信号矩阵每一行是一帧。 # 计算每帧的采样点数和步进采样点数 frame_length int(round(frame_len * sample_rate)) frame_step int(round(frame_stride * sample_rate)) signal_length len(signal) # 计算总帧数并确保至少有一帧 num_frames int(np.ceil(float(np.abs(signal_length - frame_length)) / frame_step)) # 填充信号使得所有帧都能有完整的frame_length个采样点 pad_signal_length num_frames * frame_step frame_length z np.zeros((pad_signal_length - signal_length)) pad_signal np.append(signal, z) # 构建帧的索引矩阵这个技巧很高效避免了显式循环 indices np.tile(np.arange(0, frame_length), (num_frames, 1)) \ np.tile(np.arange(0, num_frames * frame_step, frame_step), (frame_length, 1)).T frames pad_signal[indices.astype(np.int32, copyFalse)] # 加汉明窗 frames * np.hamming(frame_length) return frames # 使用上面的预加重信号进行分帧 frames frame_signal(emphasized_signal, sample_rate, frame_len0.025, frame_stride0.01) print(f信号总长度: {len(original_signal)}) print(f分帧后得到 {frames.shape[0]} 帧每帧 {frames.shape[1]} 个点)经过这一步我们就把一个一维的长信号变成了一个二维矩阵frames它的形状是(帧数, 每帧采样点数)。后续所有的操作都是在这个矩阵的每一帧上独立进行的。3. 窥探频率的秘密从时域到频域现在我们手头是一帧一帧的时域信号。但声音的特征比如元音‘a’和‘i’的区别更主要体现在它们的频率成分上。‘a’音的能量可能集中在低频而‘i’音的能量可能集中在中高频。所以我们需要把每一帧信号从时域变换到频域这个工具就是快速傅里叶变换。3.1 计算功率谱对每一帧加窗后的信号做FFT我们就能得到这一帧信号的频谱。频谱是一个复数包含了每个频率成分的幅度和相位信息。在语音识别中我们通常更关心能量的分布所以取它的幅度谱的平方就得到了功率谱。def calculate_power_spectrum(frames, NFFT512): 计算每一帧信号的功率谱。 参数: frames: 分帧加窗后的信号矩阵。 NFFT: FFT的点数通常取大于等于帧长的2的整数次幂用于频率插值。 返回: power_spectrum: 功率谱矩阵形状为 (帧数, NFFT/21) # 使用实数FFT (rfft)因为我们只关心正频率部分可以节省一半计算量 mag_frames np.absolute(np.fft.rfft(frames, NFFT)) # 计算功率谱幅度平方除以NFFT power_frames ((1.0 / NFFT) * (mag_frames ** 2)) return power_frames NFFT 512 pow_frames calculate_power_spectrum(frames, NFFT) print(f功率谱矩阵形状: {pow_frames.shape})这里NFFT512意味着我们把一帧信号比如400个点通过补零扩展到512个点再做FFT这样得到的频谱线更密频率分辨率更高。pow_frames的每一行代表一帧每一列代表一个频率区间的能量。频率范围从0Hz到奈奎斯特频率即采样率的一半8000Hz。3.2 转换到梅尔刻度模仿人耳得到功率谱后直接用它作为特征还有一个问题它是对线性频率刻度的。但人耳对频率的感知并不是线性的。对于1000Hz以下的声音我们能轻易分辨出50Hz和100Hz的差别差50Hz但对于5000Hz以上的声音我们可能听不出5050Hz和5100Hz的差别同样差50Hz。人耳更像一个对数频率刻度尺。梅尔刻度Mel Scale就是一种模拟人耳听觉感知的非线性频率刻度。在梅尔刻度下如果两段语音的梅尔频率相差两倍我们听起来音调也大概相差两倍。从物理频率f(Hz) 到梅尔频率m的转换公式是[ m 2595 \times \log_{10}(1 \frac{f}{700}) ]这个公式是经验公式你会发现低频时变化快高频时变化慢正好对应了人耳的特性。我们的下一步就是在梅尔刻度上设计一组滤波器用来对功率谱进行“滤波”和“汇总”。4. 构建梅尔滤波器组提取听觉感知特征我们不会直接使用所有频率点的能量那样维度太高而且很多信息是冗余的。我们会设计一组三角滤波器这组滤波器的中心频率在梅尔刻度上是均匀分布的但在线性频率刻度上则是低频密集、高频稀疏。4.1 设计三角形滤波器组假设我们想得到40个梅尔滤波器一个常用值。首先我们把整个频率范围0Hz到8000Hz转换到梅尔刻度然后在梅尔刻度上均匀地取42个点因为首尾两个点作为边界中间40个点是滤波器的中心频率再把这些点转换回线性频率刻度。最后根据这些频率点在FFT频谱上的位置称为bin构造出40个三角形滤波器。def get_filterbanks(nfilt40, NFFT512, sample_rate16000, low_freq0, high_freqNone): 生成梅尔滤波器组。 参数: nfilt: 滤波器数量。 NFFT: FFT点数。 sample_rate: 采样率。 low_freq: 最低频率Hz。 high_freq: 最高频率Hz默认为采样率的一半。 返回: filterbanks: 滤波器组矩阵形状为 (nfilt, int(NFFT/2)1) if high_freq is None: high_freq sample_rate / 2 # 1. 将线性频率边界转换为梅尔频率 low_mel hz2mel(low_freq) high_mel hz2mel(high_freq) # 2. 在梅尔刻度上等间距产生nfilt2个点 mel_points np.linspace(low_mel, high_mel, nfilt 2) # 3. 将梅尔点转回线性频率Hz hz_points mel2hz(mel_points) # 4. 计算这些频率点对应FFT频谱的哪个bin (索引) # bin floor((NFFT 1) * hz_points / sample_rate) bin_indices np.floor((NFFT 1) * hz_points / sample_rate).astype(int) # 5. 创建滤波器组矩阵 fbank np.zeros((nfilt, int(NFFT / 2 1))) for m in range(1, nfilt 1): f_m_minus bin_indices[m - 1] # 左顶点 f_m bin_indices[m] # 中心顶点 f_m_plus bin_indices[m 1] # 右顶点 # 构建上升斜坡 (f_m_minus 到 f_m) for k in range(f_m_minus, f_m): if f_m ! f_m_minus: # 避免除零 fbank[m - 1, k] (k - f_m_minus) / (f_m - f_m_minus) # 构建下降斜坡 (f_m 到 f_m_plus) for k in range(f_m, f_m_plus): if f_m_plus ! f_m: # 避免除零 fbank[m - 1, k] (f_m_plus - k) / (f_m_plus - f_m) return fbank # 辅助函数Hz 到 Mel 的转换 def hz2mel(hz): return 2595 * np.log10(1 hz / 700.0) # 辅助函数Mel 到 Hz 的转换 def mel2hz(mel): return 700 * (10 ** (mel / 2595.0) - 1) # 生成滤波器组 nfilt 40 fbank get_filterbanks(nfiltnfilt, NFFTNFFT, sample_ratesample_rate) # 可视化滤波器组 import matplotlib.pyplot as plt plt.figure(figsize(10, 4)) for i in range(nfilt): plt.plot(fbank[i]) plt.title(fMel Filter Bank ({nfilt} filters)) plt.xlabel(FFT Bin) plt.ylabel(Weight) plt.show()运行这段代码你会看到一张图上面有40个三角形它们在低频部分窄而密集在高频部分宽而稀疏。每个三角形滤波器的作用是对落在它覆盖范围内的FFT频谱能量进行加权求和。低频的滤波器窄能捕捉细致的频率变化高频的滤波器宽对频率变化不敏感这正好模仿了人耳的特性。4.2 应用滤波器组得到梅尔频谱图现在我们用这个滤波器组去“过滤”我们之前计算好的功率谱。具体操作是将每一帧的功率谱一个长度为NFFT/21的向量与滤波器组矩阵nfilt行NFFT/21列相乘。相乘的结果是一个新的矩阵其形状为(帧数, nfilt)。这个矩阵的每一行代表一帧每一列代表该帧信号在某个梅尔滤波器频带上的总能量。我们称之为梅尔频谱图。# 应用滤波器组到功率谱上 filter_banks np.dot(pow_frames, fbank.T) # 注意是点乘滤波器组的转置 # 为了防止取log时出现无穷大将0值替换为一个非常小的正数 filter_banks np.where(filter_banks 0, np.finfo(float).eps, filter_banks) # 取对数将乘性关系人耳对声音强度的感知近似对数关系转化为加性关系 filter_banks 20 * np.log10(filter_banks) # 单位是分贝(dB) print(f梅尔滤波器组能量矩阵形状: {filter_banks.shape})得到的filter_banks就是梅尔频谱图。你可以把它想象成一张灰度图横轴是时间帧序号纵轴是梅尔滤波器序号代表不同的频带每个像素的亮度代表了该时刻、该频带的能量强弱。这张图已经比原始的频谱图更接近人耳的听觉表示了。5. 最终的魔法离散余弦变换与MFCC系数我们已经得到了梅尔频谱图但它还有一个问题这40个滤波器输出的能量值之间是高度相关的。因为相邻的三角形滤波器有很大的重叠区域所以它们的输出值很相似。对于很多机器学习算法来说高度相关的特征不仅冗余还可能影响模型性能。我们需要一种方法来压缩这些特征并去除它们之间的相关性。这就是离散余弦变换大显身手的时候了。5.1 离散余弦变换去相关DCT和FFT有点像但它只使用余弦函数而且处理的是实数信号。它对梅尔频谱的每一帧一个40维的向量做变换可以产生一组新的系数。这些系数中前面的几个代表了频谱的“包络”大体形状后面的系数代表了频谱的“细节”精细结构。在语音识别中我们通常只取前12-13个系数因为它们包含了最重要的信息并且已经实现了很好的去相关和压缩。from scipy.fftpack import dct def calculate_mfcc(filter_banks, num_ceps13): 从梅尔滤波器组能量计算MFCC系数。 参数: filter_banks: 梅尔滤波器组能量矩阵形状 (帧数, nfilt)。 num_ceps: 要保留的MFCC系数个数通常12-13。 返回: mfcc: MFCC系数矩阵形状 (帧数, num_ceps)。 # 应用DCTtype2是常用的DCT-II型 # axis1 表示对每一行即每一帧进行变换 mfcc dct(filter_banks, type2, axis1, normortho) # 通常我们丢弃第0个系数它代表该帧的总能量我们后面会单独加回去取第1到第num_ceps个系数 mfcc mfcc[:, 1:(num_ceps1)] return mfcc num_ceps 13 mfcc calculate_mfcc(filter_banks, num_cepsnum_ceps) print(fMFCC系数矩阵形状: {mfcc.shape})现在mfcc矩阵的每一行就是一帧语音的13个静态MFCC系数。它们比原来的40维梅尔能量更紧凑相关性也更低。5.2 添加动态信息Delta与Delta-Delta系数静态的MFCC系数描述了每一帧语音的静态频谱特性。但语音是动态变化的辅音和元音之间的过渡、音调的变化都蕴含在帧与帧之间的变化中。为了捕捉这种动态信息我们会计算静态MFCC系数的一阶差分Delta系数和二阶差分Delta-Delta系数。Delta系数可以看作是对MFCC系数在时间上求导数反映了特征的变化速度比如从辅音‘b’爆破到元音‘a’的过渡。Delta-Delta系数是Delta系数的差分反映了变化速度的变化加速度。def calculate_delta(feat, N2): 计算特征如MFCC的Delta系数一阶差分。 参数: feat: 特征矩阵形状 (帧数, 特征维度)。 N: 用于计算差分的窗口半径通常取2。 返回: delta_feat: Delta系数矩阵。 if N 1: raise ValueError(N must be an integer 1) NUMFRAMES feat.shape[0] denominator 2 * sum([i**2 for i in range(1, N1)]) # 在特征矩阵前后各填充N帧使用边缘值填充 padded np.pad(feat, ((N, N), (0, 0)), modeedge) delta_feat np.zeros_like(feat) for t in range(NUMFRAMES): # 使用一个窗口内的加权平均来计算差分比简单的前后帧相减更平滑 delta_feat[t] np.dot(np.arange(-N, N1), padded[t: t2*N1]) / denominator return delta_feat # 计算Delta和Delta-Delta系数 delta calculate_delta(mfcc) delta_delta calculate_delta(delta) # 将静态系数、一阶差分、二阶差分在特征维度上拼接起来 mfcc_features np.hstack([mfcc, delta, delta_delta]) print(f最终的MFCC特征矩阵形状39维: {mfcc_features.shape})现在mfcc_features的每一帧就是一个39维的特征向量13静态 13一阶差分 13二阶差分。这就是语音识别中最经典、最常用的MFCC特征了。5.3 能量与倒谱提升在实际应用中我们通常还会做两件事添加帧能量每一帧信号的总能量也是一个非常重要的特征特别是用于区分清音和浊音。我们可以把它作为第0维特征加在13维静态MFCC前面这样静态部分就变成了14维总特征维度变成42维。倒谱均值归一化为了消除录音时信道比如不同麦克风的影响通常会对MFCC系数在时间维度上做归一化减去所有帧的均值。倒谱提升在DCT之后我们可以对MFCC系数乘以一个提升窗口来放大高阶系数代表更精细的频谱细节的作用这被称为倒谱提升有时能提升在噪声环境下的识别率。# 1. 计算帧能量在分帧加窗后做FFT之前 frame_energy np.sum(frames ** 2, axis1) # 对每一帧求平方和 frame_energy np.where(frame_energy 0, np.finfo(float).eps, frame_energy) frame_energy np.log(frame_energy) # 取对数 frame_energy frame_energy.reshape(-1, 1) # 变成列向量 # 2. 将能量拼接到静态MFCC前面 mfcc_with_energy np.hstack([frame_energy, mfcc]) # 现在静态部分是14维 # 重新计算Delta和Delta-Delta基于新的14维静态特征 delta_new calculate_delta(mfcc_with_energy) delta_delta_new calculate_delta(delta_new) final_features np.hstack([mfcc_with_energy, delta_new, delta_delta_new]) print(f添加能量后的最终特征矩阵形状: {final_features.shape}) # 3. 倒谱均值归一化 (CMN) final_features - np.mean(final_features, axis0, keepdimsTrue) # 4. 倒谱提升可选 cep_lifter 22 (nframes, ncoeff) mfcc_with_energy.shape n np.arange(ncoeff) lift 1 (cep_lifter / 2) * np.sin(np.pi * n / cep_lifter) mfcc_with_energy * lift # 注意提升通常只应用于静态系数然后再重新计算差分6. 实战整合与可视化让我们把上面所有的步骤整合到一个完整的函数里并看看我们提取出的MFCC特征长什么样。我会用一个真实的语音片段来演示。import numpy as np import scipy.io.wavfile as wav import matplotlib.pyplot as plt from scipy.fftpack import dct def extract_mfcc(audio_path, nfilt40, num_ceps13, frame_len0.025, frame_stride0.01, NFFT512, pre_emphasis_coeff0.97, add_energyTrue, lifter22): 从wav文件中提取MFCC特征完整流程。 # 1. 读取音频 sample_rate, signal wav.read(audio_path) if signal.ndim 1: signal signal[:, 0] # 取左声道 signal signal.astype(np.float32) # 2. 预加重 emphasized_signal np.append(signal[0], signal[1:] - pre_emphasis_coeff * signal[:-1]) # 3. 分帧与加窗 frame_length int(round(frame_len * sample_rate)) frame_step int(round(frame_stride * sample_rate)) signal_length len(emphasized_signal) num_frames int(np.ceil(float(np.abs(signal_length - frame_length)) / frame_step)) pad_signal_length num_frames * frame_step frame_length z np.zeros((pad_signal_length - signal_length)) pad_signal np.append(emphasized_signal, z) indices np.tile(np.arange(0, frame_length), (num_frames, 1)) \ np.tile(np.arange(0, num_frames * frame_step, frame_step), (frame_length, 1)).T frames pad_signal[indices.astype(np.int32, copyFalse)] frames * np.hamming(frame_length) # 4. 计算功率谱 mag_frames np.absolute(np.fft.rfft(frames, NFFT)) pow_frames ((1.0 / NFFT) * (mag_frames ** 2)) # 5. 计算梅尔滤波器组并应用 low_freq 0 high_freq sample_rate / 2 low_mel 2595 * np.log10(1 low_freq / 700.0) high_mel 2595 * np.log10(1 high_freq / 700.0) mel_points np.linspace(low_mel, high_mel, nfilt 2) hz_points 700 * (10 ** (mel_points / 2595.0) - 1) bin_indices np.floor((NFFT 1) * hz_points / sample_rate).astype(int) fbank np.zeros((nfilt, int(NFFT / 2 1))) for m in range(1, nfilt 1): f_m_minus, f_m, f_m_plus bin_indices[m-1], bin_indices[m], bin_indices[m1] for k in range(f_m_minus, f_m): fbank[m-1, k] (k - f_m_minus) / (f_m - f_m_minus) for k in range(f_m, f_m_plus): fbank[m-1, k] (f_m_plus - k) / (f_m_plus - f_m) filter_banks np.dot(pow_frames, fbank.T) filter_banks np.where(filter_banks 0, np.finfo(float).eps, filter_banks) filter_banks 20 * np.log10(filter_banks) # 6. 计算静态MFCC mfcc dct(filter_banks, type2, axis1, normortho) # 7. 可选添加帧能量作为第0维系数 if add_energy: energy np.sum(frames ** 2, axis1) energy np.where(energy 0, np.finfo(float).eps, energy) energy np.log(energy).reshape(-1, 1) mfcc[:, 0] energy.squeeze() # 用能量替换DCT的第0系数 # 8. 取前num_ceps个系数如果加了能量这里就取前num_ceps个其中第0个是能量 mfcc mfcc[:, :num_ceps] # 9. 可选倒谱提升 if lifter 0: ncoeff mfcc.shape[1] n np.arange(ncoeff) lift 1 (lifter / 2) * np.sin(np.pi * n / lifter) mfcc * lift # 10. 计算Delta和Delta-Delta系数 delta calculate_delta(mfcc) delta_delta calculate_delta(delta) # 11. 特征拼接与均值归一化 features np.hstack([mfcc, delta, delta_delta]) features - np.mean(features, axis0, keepdimsTrue) return features, sample_rate # 使用函数提取特征 audio_file your_audio.wav # 替换成你的音频文件路径 mfcc_features, sr extract_mfcc(audio_file, num_ceps13, add_energyTrue) # 可视化MFCC特征热力图 plt.figure(figsize(12, 4)) # 绘制静态MFCC部分前13维不包括能量 plt.imshow(mfcc_features[:, :13].T, aspectauto, originlower, cmapjet, extent[0, mfcc_features.shape[0]/ (sr*0.01), 0, 13]) # 时间轴换算 plt.colorbar(labelMFCC Coefficient Value) plt.xlabel(Time (s)) plt.ylabel(MFCC Coefficient Index) plt.title(MFCC Features (Static Coefficients)) plt.tight_layout() plt.show()运行这段代码你会得到一张MFCC特征的热力图。横轴是时间纵轴是13个MFCC系数从低频到高频信息。颜色越亮代表该系数在该时刻的值越大。不同的语音比如‘啊’和‘咦’会在这张图上呈现出明显不同的模式这就是机器用来区分不同语音的“指纹”。我在实际做语音识别项目时MFCC是第一个会尝试的特征。它的计算速度很快效果也很稳定尤其是在资源受限的环境下比如嵌入式设备MFCC加上传统的GMM-HMM模型依然能打。虽然现在深度学习模型可以直接从原始频谱甚至波形学习特征但理解MFCC这个经典算法的每一步能让你对语音信号的本质有更深的洞察。当你看到模型输出的识别结果时你就能大概知道是MFCC提取出的哪些频带能量和动态变化在起作用。这种把复杂物理信号一步步抽象成数学特征的过程本身就是AI工程里最迷人的部分之一。