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
小技巧: 预加重系数 α 越接近 1,高频提升越明显。如果做的是噪声环境下的语音识别,我建议 α 取 0.95 左右,避免过度放大高频噪声。
注意: 预加重是在分帧之前做的,不是在分帧之后。顺序搞反了,效果会大打折扣。

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,结果识别率反而下降了——帧太长,平稳假设就不成立了。

核心原则: 帧长要足够短,保证帧内信号平稳;帧移要足够小,保证帧间信息不丢失。一般帧移取帧长的 1/3 到 1/2。

分帧的代码实现也很直接:

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
避坑指南: 我曾经在做一个远场语音识别项目时,忘了加窗,直接对分帧后的信号做 FFT。结果频谱上全是毛刺,共振峰根本看不清楚。后来加上汉明窗,效果立竿见影。所以加窗这一步千万别省。

3.4 本章知识体系总览

下面这张图把预加重、分帧、加窗的流程和关键参数串起来了,方便你理解整个链路:

预加重与分帧加窗流程 预加重 → 分帧 → 加窗 流程总览 原始语音信号 预加重 分帧 加窗 关键参数与细节 预加重参数 • 公式:y[n] = x[n] - α·x[n-1] • α 范围:0.95 ~ 0.99 • 推荐值:0.97 分帧参数 • 帧长:20~30 ms • 帧移:10~15 ms • 帧移 = 帧长 × (1/3 ~ 1/2) 加窗参数 • 汉明窗:主瓣窄 • 汉宁窗:旁瓣低 • 语音识别推荐汉明窗 ⚠ 顺序不能乱:先预加重 → 再分帧 → 最后加窗 ⚠ 帧长不能太长,否则平稳假设不成立 ⚠ 加窗不能省略,否则频谱泄漏严重 输出:加窗后的帧矩阵

3.5 小结

这一章我们讲了三个核心操作:

  • 预加重:用高通滤波器提升高频,系数 α 取 0.97 左右。
  • 分帧:帧长 25ms、帧移 10ms 是语音识别的黄金配置。
  • 加窗:汉明窗是首选,减少频谱泄漏。

这三个步骤是声学特征提取的基石。你想想看,如果连预加重系数都设错了,后面提取出来的 MFCC 或者 Filter Bank 特征能准吗?所以别嫌这些步骤简单,打好基础比什么都重要。

一句话总结: 预加重提高频,分帧切信号,加窗抑泄漏。三步走完,信号就准备好了。