3. 时域特征提取(上):短时能量、过零率、静音检测(VAD)的原理与实现
各位同学,欢迎来到时域特征提取的部分。
很多人一上来就扎进频域,觉得时域太简单。其实不然。时域特征虽然直观,但它是很多工程落地的基石。我做过不少语音唤醒的项目,最后发现,真正决定系统功耗和响应速度的,恰恰是时域那几步。
今天咱们就聊聊三个最基础、也最实用的时域特征:短时能量、过零率,以及它们组合起来做的静音检测(VAD)。
核心观点:时域特征是音频信号的“第一印象”。计算快、物理意义明确,适合做前端预处理和低功耗场景。
3.1 短时能量:语音的“音量计”
短时能量,说白了就是衡量一帧信号有多“响”。
公式很简单:对一帧内的所有采样点平方,然后求和(或取平均)。
# 短时能量计算(Python 示意)
import numpy as np
def short_time_energy(frame):
"""
frame: 一维数组,一帧的采样点
返回: 标量能量值
"""
# 平方和,就是能量
energy = np.sum(frame ** 2)
return energy
为什么先平方?因为信号有正有负,直接求和会正负抵消。平方之后,所有值都变成正的,累加才有意义。
我的经验:在实际项目中,我习惯用 np.mean(frame ** 2) 代替 np.sum。这样能量值不会随着帧长变化,阈值更容易统一。尤其是做嵌入式移植时,这个细节能省不少调试时间。
短时能量能干什么?
- 区分清音和浊音:浊音(元音)能量高,清音(辅音如 /s/、/f/)能量低。
- 检测语音起止:能量突然升高,大概率是有人开始说话了。
- 做简单的降噪:能量低于某个门限的帧,直接当噪声处理。
但要注意,短时能量对噪声很敏感。环境里有个空调嗡嗡响,能量值就下不来了。所以单独用能量做VAD,效果往往不好。
3.2 过零率:信号的“粗糙度”
过零率(Zero Crossing Rate, ZCR),统计一帧内信号穿过零轴的次数。
对于离散信号,就是相邻采样点符号变化的次数。
def zero_crossing_rate(frame):
"""
计算过零率
"""
# 符号变化检测
sign_changes = np.diff(np.sign(frame))
# 非零位置即为过零点
zcr = np.count_nonzero(sign_changes) / len(frame)
return zcr
过零率有什么物理意义?
- 清音:频率高、波形抖得厉害,过零率就高。
- 浊音:频率低、波形平滑,过零率就低。
- 噪声:通常介于两者之间,但分布比较稳定。
我遇到过一个问题:信号里如果有直流偏置,过零率会严重失真。比如麦克风电路有50Hz工频干扰,波形整体偏上,过零率直接变成0。解决办法是预处理时做一下直流移除,或者用过零率时加一个小的阈值(比如 ±0.01),避免被噪声抖动干扰。
避坑指南:我曾经在一个车载项目里直接用原始信号算过零率,结果发现车速一快,过零率就乱跳。后来排查发现是风噪引入了大量高频抖动。解决方案是在过零率计算前加一个简单的低通滤波(比如移动平均),把风噪的高频毛刺滤掉。
3.3 静音检测(VAD):把能量和过零率结合起来
单独用能量或过零率,都有缺陷。能量怕噪声,过零率怕直流。但把它们组合起来,效果就稳多了。
经典的VAD算法是双门限判决,流程如下:
- 计算特征:对每一帧,分别计算短时能量和过零率。
- 设置门限:能量高门限(THhigh)、能量低门限(THlow)、过零率门限(THzcr)。
- 粗判:
- 能量 > THhigh → 判为语音。
- 能量 < THlow → 判为静音。
- 能量在两者之间 → 看过零率,如果过零率低(像浊音)判语音,否则判静音。
- 状态机平滑:防止单帧误判,通常加一个“挂起计数”。连续N帧判为语音才真正切到语音状态,连续M帧判为静音才切回静音。
下面是一个简化的VAD实现:
class SimpleVAD:
def __init__(self, energy_high=1000, energy_low=100, zcr_thresh=0.1, hangover=5):
self.energy_high = energy_high
self.energy_low = energy_low
self.zcr_thresh = zcr_thresh
self.hangover = hangover
self.speech_count = 0
self.silence_count = 0
self.is_speech = False
def process_frame(self, frame):
energy = np.mean(frame ** 2)
zcr = zero_crossing_rate(frame)
# 粗判
if energy > self.energy_high:
decision = True
elif energy < self.energy_low:
decision = False
else:
# 中间区域,用过零率辅助判断
decision = (zcr < self.zcr_thresh)
# 状态机平滑
if decision:
self.speech_count += 1
self.silence_count = 0
if self.speech_count >= self.hangover:
self.is_speech = True
else:
self.silence_count += 1
self.speech_count = 0
if self.silence_count >= self.hangover:
self.is_speech = False
return self.is_speech
关键参数调优:门限值不是固定的。我一般会在项目初期录一段包含“静音-语音-静音”的音频,然后画出能量和过零率的曲线,手动选一个合适的门限。自动化方法可以用Otsu阈值或者基于噪声基底自适应更新。
3.4 三种特征的对比
| 特征 | 计算量 | 抗噪性 | 主要用途 | 典型值(16kHz, 20ms帧) |
|---|---|---|---|---|
| 短时能量 | 极低 | 弱 | 音量检测、粗粒度VAD | 静音帧 < 50,语音帧 > 500 |
| 过零率 | 极低 | 中(需去直流) | 清/浊音判别、端点检测辅助 | 浊音 < 0.1,清音 > 0.3 |
| 双门限VAD | 低 | 中(依赖门限) | 语音激活检测、低功耗唤醒 | 延迟约 100-300ms(含挂起) |
你想想看,这三个特征都不需要做FFT,计算量极低。在嵌入式芯片上,几百字节的RAM就能跑起来。我当年在Cortex-M4上做唤醒词检测,前端VAD就是用这套逻辑,整个系统功耗控制在10mW以内。
3.5 工程中的几个坑
- 帧长选择:太短(<10ms)能量波动大,容易误判;太长(>50ms)反应迟钝。我一般用20-30ms,16kHz采样对应320-480个采样点。
- 窗函数:计算能量时,矩形窗和汉明窗差别不大。但做过零率时,矩形窗更常用,因为它不改变信号的过零位置。
- 自适应门限:固定门限在安静环境好用,换到嘈杂环境就废了。建议用前几帧估计噪声基底,动态调整门限。
一个实用技巧:如果你发现VAD在语音末尾总是“切得太快”,把挂起计数(hangover)设大一点。我习惯设5-10帧,相当于100-200ms的延迟,人耳基本感觉不到,但能避免把尾音切掉。