3. 预加重与分帧:高通滤波器实现预加重、帧长与帧移的选择策略、加窗函数的作用
好,我们进入第三章。这一章讲的是声学特征提取里最前端的两个操作——预加重和分帧加窗。说实话,这两个步骤看起来简单,但很多人就是在这里埋下了坑。我见过不少项目,后面模型怎么调都调不好,回头一看,原来是预加重系数设错了,或者帧长帧移没匹配好应用场景。
咱们一个一个来拆解。
3.1 预加重:为什么要做?怎么做?
先问一个问题:语音信号的能量分布是什么样的?
你想想看,人声的能量主要集中在低频段,大概 300Hz 以下。高频段的能量衰减得非常快。但高频部分恰恰承载了很多重要的信息,比如辅音(/s/、/f/、/sh/)的细节。如果直接拿原始信号去提取特征,低频分量会淹没高频分量,模型根本学不到高频的细节。
预加重说白了就是一个高通滤波器。它的作用就是提升高频分量,让整个频谱变得平坦一些。这样后续的模型就能公平地对待各个频段的信息。
数学上,预加重就是一个一阶差分方程:
y[n] = x[n] - α * x[n-1]
其中 α 是预加重系数,通常取 0.95 到 0.99 之间。我个人习惯用 0.97,这个值在大多数场景下表现都不错。
来看一段 Python 实现:
import numpy as np
def pre_emphasis(signal, alpha=0.97):
"""
预加重处理
:param signal: 输入语音信号 (1D array)
:param alpha: 预加重系数,默认 0.97
:return: 预加重后的信号
"""
emphasized = np.append(signal[0], signal[1:] - alpha * signal[:-1])
return emphasized
3.2 分帧:帧长与帧移的选择策略
语音信号是非平稳的,但它在短时间内(10-30ms)可以看作是平稳的。所以我们把信号切成一小段一小段来处理,每一段就是一帧。
这里有两个关键参数:帧长和帧移。
- 帧长:每一帧包含的采样点数,对应的时间长度。
- 帧移:相邻两帧起始位置之间的偏移量,通常取帧长的一半或三分之一。
为什么要用帧移?因为相邻帧之间是连续的,如果完全不重叠,帧与帧之间的信息会丢失。重叠分帧可以保证信号的连续性。
来看一个常见的参数配置表:
| 应用场景 | 采样率 (Hz) | 帧长 (ms) | 帧移 (ms) | 帧长 (采样点) | 帧移 (采样点) |
|---|---|---|---|---|---|
| 语音识别 | 16000 | 25 | 10 | 400 | 160 |
| 说话人识别 | 16000 | 30 | 15 | 480 | 240 |
| 音乐分析 | 44100 | 50 | 25 | 2205 | 1102 |
| 语音增强 | 16000 | 20 | 10 | 320 | 160 |
我个人建议,做语音识别时就用 25ms 帧长、10ms 帧移,这是业界最成熟的配置。我曾经试过把帧长改成 40ms,结果识别率反而下降了——帧太长,平稳假设就不成立了。
分帧的代码实现也很直接:
def framing(signal, fs, frame_len_ms=25, frame_shift_ms=10):
"""
分帧处理
:param signal: 预加重后的信号
:param fs: 采样率
:param frame_len_ms: 帧长 (ms)
:param frame_shift_ms: 帧移 (ms)
:return: 帧矩阵 (num_frames x frame_len)
"""
frame_len = int(fs * frame_len_ms / 1000)
frame_shift = int(fs * frame_shift_ms / 1000)
num_frames = int(np.ceil((len(signal) - frame_len) / frame_shift)) + 1
frames = np.zeros((num_frames, frame_len))
for i in range(num_frames):
start = i * frame_shift
end = start + frame_len
if end > len(signal):
frames[i, :len(signal) - start] = signal[start:]
else:
frames[i, :] = signal[start:end]
return frames
3.3 加窗函数:汉明窗与汉宁窗
分帧之后,我们直接对每一帧做 FFT 行不行?理论上可以,但效果不好。为什么呢?
因为分帧相当于对信号做了一个矩形窗截断。矩形窗在时域上突然截断,在频域上会引入旁瓣泄漏——就是频谱上出现很多不该有的能量。说白了,就是频率分辨率被污染了。
加窗的目的就是缓解这个问题。窗函数在帧的两端平滑地衰减到零,减少截断带来的频谱泄漏。
最常用的两个窗函数是汉明窗和汉宁窗。
它们的数学表达式:
- 汉明窗:w[n] = 0.54 - 0.46 * cos(2πn / (N-1))
- 汉宁窗:w[n] = 0.5 - 0.5 * cos(2πn / (N-1))
区别在哪?汉明窗在两端不是完全归零的,它保留了 0.08 的幅度。汉宁窗两端完全归零。所以汉明窗的旁瓣抑制稍弱,但主瓣宽度更窄;汉宁窗旁瓣抑制更强,但主瓣稍宽。
来看一个对比表:
| 窗函数 | 主瓣宽度 | 旁瓣衰减 | 两端值 | 推荐场景 |
|---|---|---|---|---|
| 矩形窗 | 最窄 | -13 dB | 1.0 | 不推荐用于语音 |
| 汉明窗 | 较窄 | -43 dB | 0.08 | 语音识别(常用) |
| 汉宁窗 | 稍宽 | -32 dB | 0.0 | 音乐分析、频谱平滑 |
我个人习惯在语音识别任务中用汉明窗。为什么?因为汉明窗的主瓣更窄,频率分辨率更高,对语音中的共振峰细节保留得更好。汉宁窗我一般在做频谱平滑或者音乐分析时用。
加窗的代码实现:
def apply_window(frames, window_type='hamming'):
"""
对每一帧加窗
:param frames: 帧矩阵 (num_frames x frame_len)
:param window_type: 窗类型,'hamming' 或 'hanning'
:return: 加窗后的帧矩阵
"""
frame_len = frames.shape[1]
if window_type == 'hamming':
window = np.hamming(frame_len)
elif window_type == 'hanning':
window = np.hanning(frame_len)
else:
raise ValueError("Unsupported window type")
return frames * window
3.4 本章知识体系总览
下面这张图把预加重、分帧、加窗的流程和关键参数串起来了,方便你理解整个链路:
3.5 小结
这一章我们讲了三个核心操作:
- 预加重:用高通滤波器提升高频,系数 α 取 0.97 左右。
- 分帧:帧长 25ms、帧移 10ms 是语音识别的黄金配置。
- 加窗:汉明窗是首选,减少频谱泄漏。
这三个步骤是声学特征提取的基石。你想想看,如果连预加重系数都设错了,后面提取出来的 MFCC 或者 Filter Bank 特征能准吗?所以别嫌这些步骤简单,打好基础比什么都重要。