2. 时域特征提取:短时能量、短时过零率、短时自相关函数、端点检测(VAD)

时域特征,说白了就是直接对着音频信号的波形下手。不做傅里叶变换,不关心频率成分,就看信号幅度随时间怎么变。我刚开始做语音处理那会儿,觉得时域特征太“原始”了,总想上频域。后来踩过几次坑才明白——很多场景下,时域特征又快又稳,尤其是做端点检测和静音切除,频域反而容易过拟合。

这一章我们聊四个经典时域特征:短时能量、短时过零率、短时自相关函数,以及它们组合起来做端点检测(VAD)。每个我都会给出数学定义、代码实现,以及我在项目中踩过的坑。

2.1 短时能量(Short-Time Energy)

短时能量衡量的是某一帧信号的“响度”。语音段能量高,静音段能量低——这是最直观的区分依据。

定义很简单:对第 n 帧,计算所有采样点的平方和。

E(n) = Σ_{m=0}^{N-1} [x_n(m)]²

其中 N 是帧长,x_n(m) 是第 n 帧的第 m 个采样点。

实际部署时,我一般用这个实现:

import numpy as np

def short_time_energy(frame):
    """
    计算单帧的短时能量
    frame: 1D numpy array, 帧数据
    返回: 标量能量值
    """
    return np.sum(frame ** 2) / len(frame)  # 归一化,避免帧长影响
我的经验: 归一化很重要。不同帧长下能量值会差很多,除以帧长后阈值才好统一。我在一个嵌入式项目里吃过这个亏——帧长从256改成512后,VAD全崩了,排查半天才发现是能量阈值没跟着调。

短时能量的用途很直接:

  • 静音检测:能量低于阈值就判为静音
  • 语音活动检测:能量突变往往意味着说话开始或结束
  • 端点检测辅助:配合过零率一起用,准确率更高

2.2 短时过零率(Short-Time Zero Crossing Rate)

过零率统计一帧内信号穿过零轴的次数。说白了就是看波形“抖”得有多快。

清音(如 /s/、/f/)频率高,过零率大;浊音(如 /a/、/i/)频率低,过零率小。这个特性在区分清浊音时特别好用。

定义:

Z(n) = (1/2) * Σ_{m=1}^{N-1} |sgn[x_n(m)] - sgn[x_n(m-1)]|

其中 sgn 是符号函数,正值返回 1,负值返回 -1,零返回 0。

代码实现:

def zero_crossing_rate(frame):
    """
    计算单帧的短时过零率
    frame: 1D numpy array
    返回: 标量 ZCR 值
    """
    # 符号变化次数
    signs = np.sign(frame)
    zcr = np.sum(np.abs(np.diff(signs))) / (2 * len(frame))
    return zcr
注意: 直流偏置会严重干扰过零率计算。如果信号有直流分量,过零率会偏低甚至为零。我建议在分帧前先做高通滤波(比如截止频率 80Hz),或者直接减去均值。

过零率的典型应用场景:

  • 清浊音判别:浊音 ZCR 通常 < 0.2,清音 > 0.3
  • 端点检测:静音段 ZCR 低且稳定,语音段 ZCR 波动大
  • 打击乐检测:打击乐信号过零率突变明显

2.3 短时自相关函数(Short-Time Auto-Correlation Function)

自相关函数衡量信号与自身延迟版本之间的相似度。对于周期信号(比如浊音),自相关会在基音周期处出现峰值。

定义:

R_n(k) = Σ_{m=0}^{N-1-k} x_n(m) * x_n(m + k)

其中 k 是延迟量,取值范围通常为 0 到 N-1。

实现时要注意计算效率——直接按定义算复杂度是 O(N²),部署时扛不住。我一般用两种优化方式:

  1. FFT加速:利用 Wiener–Khinchin 定理,自相关 = IFFT(|FFT(x)|²)
  2. 截断计算:只算前几百个延迟点(基音周期一般不超过 20ms)
def autocorrelation_fft(frame):
    """
    使用 FFT 加速计算自相关函数
    frame: 1D numpy array
    返回: 自相关序列(前 N 点)
    """
    N = len(frame)
    # 补零到 2N 避免循环卷积
    fft_len = 2 ** int(np.ceil(np.log2(2 * N)))
    spec = np.fft.rfft(frame, n=fft_len)
    power = np.abs(spec) ** 2
    acf = np.fft.irfft(power)[:N]
    return acf / acf[0]  # 归一化到 [0, 1]
核心用途: 自相关函数的第一个峰值位置(排除 k=0)对应基音周期。我在做语音合成时,基音检测全靠这个——虽然现在有更先进的算法,但自相关法在干净语音上依然是最稳的。

2.4 端点检测(VAD)

端点检测的目标是:从一段音频里,精确找到语音开始和结束的位置。说白了就是“切掉静音,留下人声”。

我见过很多团队在 VAD 上翻车——要么切多了把词头切掉,要么切少了留一大段静音。其实核心就两点:特征选对,阈值调好。

最经典的双门限法,结合了短时能量和过零率:

  1. 高能量门限:确定语音的“肯定存在”区域
  2. 低能量门限:扩展语音边界,避免切掉弱音
  3. 过零率门限:在低能量段区分清音和静音

流程图如下:

双门限 VAD 流程图 输入音频帧 计算短时能量 + 过零率 能量 > 高门限? → 标记为“语音段” 是 能量 > 低门限 且 过零率 > 门限? 是 标记为“语音段” 输出语音起止端点 否 否 高门限:确定语音存在 低门限+ZCR:扩展边界

代码实现:

def vad_double_threshold(frames, energy_high, energy_low, zcr_thresh, min_speech_len=5):
    """
    双门限 VAD
    frames: list of 1D numpy arrays
    energy_high, energy_low: 能量门限(归一化后)
    zcr_thresh: 过零率门限
    min_speech_len: 最小语音段长度(帧数),用于去毛刺
    返回: (start_frame, end_frame) 语音段的起止帧索引
    """
    labels = []
    for frame in frames:
        energy = short_time_energy(frame)
        zcr = zero_crossing_rate(frame)

        if energy > energy_high:
            labels.append(1)
        elif energy > energy_low and zcr > zcr_thresh:
            labels.append(1)
        else:
            labels.append(0)

    # 后处理:去除短时噪声
    # 将长度小于 min_speech_len 的语音段标记为静音
    count = 0
    for i in range(len(labels)):
        if labels[i] == 1:
            count += 1
        else:
            if 0 < count < min_speech_len:
                for j in range(i - count, i):
                    labels[j] = 0
            count = 0

    # 找到第一个和最后一个 1
    speech_indices = np.where(np.array(labels) == 1)[0]
    if len(speech_indices) == 0:
        return None, None
    return speech_indices[0], speech_indices[-1]
避坑指南: 我曾经在一个车载语音项目里,VAD 总是把“嘶”声切掉一半。后来发现是过零率门限设得太高,清音被误判为静音。解决办法是:用一段带清音的录音做统计,取过零率的 70% 分位点作为门限,而不是拍脑袋定一个固定值。

2.5 四种特征的对比与选型

部署时选哪个特征,取决于你的硬件和场景。我整理了一张表,方便你快速决策:

特征 计算量 主要用途 部署注意事项
短时能量 极低(O(N)) 静音检测、VAD 主特征 需归一化,避免帧长影响
短时过零率 极低(O(N)) 清浊音判别、VAD 辅助 先去除直流分量
短时自相关 中(O(N log N) FFT) 基音检测、周期估计 FFT 实现,注意补零
双门限 VAD 低(组合特征) 语音端点检测 门限需根据信噪比自适应
我的建议: 如果你在 MCU 或 DSP 上部署,优先用短时能量 + 过零率做 VAD,计算量几乎可以忽略。自相关函数虽然强大,但 FFT 在低算力芯片上可能扛不住——这时候可以考虑用 AMDF(平均幅度差函数)替代,只做加减法,不做乘法。