4、时域特征提取(一):短时能量、短时平均幅度、过零率与VAD基础
各位同学,欢迎来到时域特征提取的第一讲。
说实话,很多做语音的同行一上来就扎进频谱、MFCC里,觉得时域特征太简单、不够"高级"。但我个人经验告诉我——时域特征是声学分析的基石。你想想看,一个端点检测做不好,后面提取再花哨的特征也是白搭。我在做语音唤醒项目时,就吃过这个亏:模型在安静环境下跑得挺好,一到嘈杂的办公室就疯狂误触发。后来排查发现,问题就出在VAD(语音活动检测)的时域阈值设得太粗糙。
所以,咱们今天就把这几个最基础的时域特征彻底吃透:短时能量(STE)、短时平均幅度、过零率(ZCR),以及它们如何组合起来做静音检测和端点检测。
4.1 为什么需要"短时"分析?
语音信号是非平稳的。但有一个关键假设:在10~30毫秒这个尺度上,信号是平稳的。这就是"短时"处理的理论基础。
我习惯把语音信号想象成一条蜿蜒的河流。整条河的水流变化无常,但如果你只看一小段河面,它的流速、波纹几乎是稳定的。我们用一个固定长度的窗口(比如25毫秒)去截取信号,这个窗口就叫帧。然后对每一帧单独计算能量、幅度、过零率等特征。
核心概念:短时分析 = 加窗 + 分帧 + 逐帧计算特征。
帧长通常取20~30ms,帧移取帧长的一半(10~15ms),保证帧与帧之间有重叠,避免信息丢失。
4.2 短时能量(Short-Time Energy, STE)
短时能量,说白了就是衡量一帧信号有多"响"。
4.2.1 数学原理
对于第 \( n \) 帧信号 \( x_n(m) \),其短时能量定义为:
E_n = Σ_{m=0}^{N-1} [x_n(m)]²
其中 \( N \) 是帧长。注意,这里是对信号值平方再求和。为什么要平方?两个原因:
- 保证能量值为正(信号有正有负,直接求和会正负抵消)
- 放大高幅值样本的贡献(响的声音在能量上会显著高于轻的声音)
4.2.2 Python实现
实现起来其实很简单。我一般用NumPy的向量化操作,比for循环快一个数量级。
import numpy as np
def short_time_energy(signal, frame_size, hop_size):
"""
计算短时能量
:param signal: 输入信号 (1D array)
:param frame_size: 帧长(样本点数)
:param hop_size: 帧移(样本点数)
:return: 能量序列
"""
num_frames = (len(signal) - frame_size) // hop_size + 1
energy = np.zeros(num_frames)
for i in range(num_frames):
start = i * hop_size
end = start + frame_size
frame = signal[start:end]
energy[i] = np.sum(frame ** 2)
return energy
避坑指南:我曾经在计算能量时忘记检查帧尾是否越界。如果信号长度不是帧移的整数倍,最后一帧可能会访问到不存在的样本。建议在循环前用 min(end, len(signal)) 做截断,或者直接丢弃不完整的最后一帧。
4.3 短时平均幅度
短时能量有个小缺点:它对信号值做了平方,导致动态范围太大。安静环境和说话声的能量可能差好几个数量级,画图时很难看清细节。
短时平均幅度就是来解决这个问题的。它用绝对值代替平方:
M_n = (1/N) * Σ_{m=0}^{N-1} |x_n(m)|
说白了,就是取一帧内所有样本的绝对值,然后求平均。这样既保留了能量信息,又压缩了动态范围。
4.3.1 代码实现
def short_time_magnitude(signal, frame_size, hop_size):
num_frames = (len(signal) - frame_size) // hop_size + 1
magnitude = np.zeros(num_frames)
for i in range(num_frames):
start = i * hop_size
end = start + frame_size
frame = signal[start:end]
magnitude[i] = np.mean(np.abs(frame))
return magnitude
嗯,这里要注意:平均幅度对噪声更敏感。如果背景有持续的底噪,幅度值会整体抬升,不像能量那样可以通过平方操作把弱噪声"压下去"。所以做静音检测时,我通常优先用能量,而不是幅度。
4.4 过零率(Zero-Crossing Rate, ZCR)
过零率,顾名思义,就是一帧内信号穿过零轴的次数。
为什么这个特征有用?因为不同声音的过零率差异很大:
- 清音(如/s/、/f/):频率高、波形振荡快,过零率很高
- 浊音(如/a/、/i/):频率低、波形平滑,过零率较低
- 静音/噪声:过零率通常介于两者之间,但噪声的过零率往往更随机
4.4.1 数学定义
Z_n = (1/2) * Σ_{m=1}^{N-1} |sgn[x_n(m)] - sgn[x_n(m-1)]|
其中 \( sgn() \) 是符号函数:正值取1,负值取-1,零值取0。相邻两个样本符号不同,就说明穿过了一次零轴。
4.4.2 Python实现
def zero_crossing_rate(signal, frame_size, hop_size):
num_frames = (len(signal) - frame_size) // hop_size + 1
zcr = np.zeros(num_frames)
for i in range(num_frames):
start = i * hop_size
end = start + frame_size
frame = signal[start:end]
# 计算符号变化
sign_diff = np.diff(np.sign(frame))
# 非零位置即为过零点
zcr[i] = np.sum(np.abs(sign_diff) > 0) / 2
return zcr
重要提醒:过零率对直流偏移非常敏感。如果信号有直流分量(均值不为0),过零率会严重偏低甚至为0。我建议在计算ZCR之前,先对每一帧做去直流处理:frame = frame - np.mean(frame)。
4.5 静音检测与端点检测(VAD)基础
有了能量和过零率,我们就可以做最基础的VAD了。
VAD的目标很简单:判断当前帧是语音还是非语音(静音/噪声)。
4.5.1 双门限法
这是我个人最常用的方法,也是很多工业级VAD的雏形。它同时使用能量和过零率两个特征:
- 能量门限:语音帧的能量通常高于静音帧。设定一个高门限 \( T_H \) 和一个低门限 \( T_L \)。
- 过零率门限:清音段过零率高,浊音段过零率低。设定一个过零率范围 \( [Z_{min}, Z_{max}] \)。
- 判决逻辑:
- 能量 > \( T_H \) → 判定为语音
- 能量 < \( T_L \) → 判定为静音
- 能量介于两者之间 → 结合过零率做二次判决
4.5.2 门限如何确定?
这个问题我当年也纠结过。后来在实践中总结了一个经验:
- 取前10帧(约200ms)作为背景噪声估计,计算这10帧的平均能量 \( E_{noise} \)
- 高门限 \( T_H = E_{noise} \times 10 \)(经验值,可根据实际环境调整)
- 低门限 \( T_L = E_{noise} \times 3 \)
- 过零率门限:取前10帧的过零率均值 ± 标准差
实战技巧:我曾经在工厂车间做语音采集,背景噪声很大,固定门限完全失效。后来改用自适应门限——每过1秒重新估计一次背景噪声,效果好了很多。记住:门限不是死的,要跟着环境走。
4.6 本章知识体系
下面这张图总结了本章的核心逻辑,从信号输入到特征提取再到VAD判决的完整流程:
4.7 本章小结
今天我们完成了三件事:
- 理解了短时分析为什么是语音处理的起点
- 掌握了短时能量、短时平均幅度、过零率的计算原理和Python实现
- 学会了用双门限法做基础的静音检测和端点检测
这些特征虽然简单,但组合起来威力不小。我记得有一次做会议录音的自动切分,就是用能量+过零率做的VAD,准确率达到了92%以上。当然,如果遇到更复杂的噪声环境,我们后面会引入谱减法、深度学习等方法。但基础打牢了,后面学起来才不慌。
课后建议:找一段自己的录音,分别计算STE和ZCR,观察说话段和静音段的特征差异。然后试着调一下双门限的阈值,看看VAD效果怎么变化。动手做一遍,比看十遍代码都管用。