3. 时域特征提取(上):短时能量、过零率、静音检测(VAD)的原理与实现

各位同学,欢迎来到时域特征提取的部分。

很多人一上来就扎进频域,觉得时域太简单。其实不然。时域特征虽然直观,但它是很多工程落地的基石。我做过不少语音唤醒的项目,最后发现,真正决定系统功耗和响应速度的,恰恰是时域那几步。

今天咱们就聊聊三个最基础、也最实用的时域特征:短时能量、过零率,以及它们组合起来做的静音检测(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算法是双门限判决,流程如下:

  1. 计算特征:对每一帧,分别计算短时能量和过零率。
  2. 设置门限:能量高门限(THhigh)、能量低门限(THlow)、过零率门限(THzcr)。
  3. 粗判:
    • 能量 > THhigh → 判为语音。
    • 能量 < THlow → 判为静音。
    • 能量在两者之间 → 看过零率,如果过零率低(像浊音)判语音,否则判静音。
  4. 状态机平滑:防止单帧误判,通常加一个“挂起计数”。连续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的延迟,人耳基本感觉不到,但能避免把尾音切掉。