4. 频域基础
时域特征,说白了就是直接在声音波形上算出来的东西。不需要做傅里叶变换,不需要搞滤波器组,就是对着原始采样点做加减乘除。
我刚开始做语音项目时,总觉得时域特征太简单,不够"高级"。后来被现实教育了——很多场景下,时域特征比频域特征更稳定、更省资源。尤其是做嵌入式端侧部署,时域特征往往是第一道关卡。
4.1 短时能量(Short-Time Energy)
短时能量,就是衡量一帧声音有多"响"。数学上很简单:
E = Σ (x[n])² (n = 0 到 N-1)
其中 x[n] 是第 n 个采样点,N 是帧长。
嗯,这里要注意——平方操作会让大信号的影响被放大。比如一个采样点从 0.1 变成 0.2,能量会从 0.01 变成 0.04,翻了 4 倍。所以短时能量对突发噪声特别敏感。
4.1.1 对数能量
有时候直接用线性能量不太方便。比如安静环境能量是 0.001,说话时是 0.1,差了 100 倍。画图时小值根本看不见。
解决办法?取对数:
E_log = 10 * log10(E + ε)
ε 是个很小的正数,防止 log(0) 爆炸。我一般取 1e-10。
对数能量把动态范围压缩了,更符合人耳的听觉特性。人耳对声音的感知本来就是对数级的——音量翻倍,你感觉到的变化远没有翻倍那么多。
4.1.2 归一化能量
不同录音设备、不同增益设置,原始能量值差异很大。直接拿原始能量设阈值,换个设备就废了。
我踩过这个坑。有一次做会议录音的 VAD,在测试机上调好的阈值,部署到客户机器上全废了——因为客户机器的麦克风灵敏度高了一倍。
解决方案是归一化:
E_norm = E / E_max
E_max 可以是历史最大值,也可以是当前帧之前若干帧的最大值。这样能量值就被限制在 [0, 1] 区间,阈值可以设成 0.1 或 0.2 这种相对值。
4.2 过零率(Zero-Crossing Rate, ZCR)
过零率,统计一帧内信号穿过零轴的次数。说白了就是数一数波形从正变负、从负变正的次数。
ZCR = (1 / N) * Σ |sign(x[n]) - sign(x[n-1])|
其中 sign() 是符号函数,正数返回 1,负数返回 -1。
过零率能反映信号的频率特性吗?能,但不精确。高频信号过零快,ZCR 高;低频信号过零慢,ZCR 低。但注意,它只对单频信号比较准,对复杂语音信号只是个粗略估计。
4.2.1 过零率的实际应用
我做过一个项目,需要在嘈杂的工厂环境中检测设备异常声音。单纯用能量不行——机器轰鸣声能量很大,但异常声音往往频率更高、过零率更高。
最后方案是:能量 + 过零率联合判断。能量超过阈值但过零率也超过阈值,才判定为异常事件。这样误报率从 30% 降到了 5% 以下。
| 信号类型 | 短时能量 | 过零率 | 典型场景 |
|---|---|---|---|
| 静音/背景噪声 | 低 | 中等 | 安静房间 |
| 浊音(元音) | 高 | 低 | 说话时的 /a/、/o/ |
| 清音(辅音) | 中低 | 高 | 说话时的 /s/、/sh/ |
| 突发噪声 | 高 | 高 | 关门声、拍桌子 |
4.3 静音检测(VAD)
VAD,Voice Activity Detection,判断当前帧是语音还是非语音。这是语音处理的第一道工序,几乎所有语音系统都离不开它。
你想想看,如果不知道哪段是语音,后面的识别、编码、增强都无从下手。
4.3.1 基于能量阈值的 VAD
最简单的 VAD:能量高于阈值就是语音,低于阈值就是静音。
if E > threshold:
is_voice = True
else:
is_voice = False
阈值怎么定?我见过三种方法:
- 固定阈值: 根据经验设一个值,比如 0.1。简单粗暴,但适应性差。
- 自适应阈值: 实时统计背景噪声的能量,阈值 = 噪声能量 × 系数(比如 2.0)。
- 双阈值: 一个高阈值、一个低阈值,配合迟滞逻辑,防止频繁切换。
4.3.2 基于能量 + 过零率的 VAD
单靠能量容易误判。比如背景噪声能量高,VAD 就会一直认为有人在说话。
加上过零率后,判断更准确:
if E > threshold_energy AND ZCR > threshold_zcr:
is_voice = True
else:
is_voice = False
为什么?因为语音信号的能量和过零率有特定关系。纯噪声的能量和过零率往往比较稳定,而语音的能量和过零率会动态变化。
4.3.3 状态机 VAD
实际工程中,我更推荐用状态机来做 VAD。它考虑了语音的连续性——人说话不会一帧一帧地断断续续。
状态机一般有 4 个状态:
- 静音(SILENCE): 初始状态,持续检测能量
- 起始(ONSET): 检测到能量突增,进入语音起始阶段
- 语音(SPEECH): 确认是语音,持续跟踪
- 结束(OFFSET): 能量下降,但还没完全退出语音状态
状态之间通过计数器和阈值控制切换。比如从 SILENCE 到 ONSET,需要连续 3 帧能量超过阈值;从 SPEECH 到 OFFSET,需要连续 5 帧能量低于阈值。
这样做的好处是:不会因为一帧的噪声就误判为语音开始,也不会因为一帧的短暂停顿就误判为语音结束。
4.4 核心逻辑流程图
下面这张图展示了时域特征提取和 VAD 的整体流程:
4.5 工程实现要点
讲完原理,说说落地时要注意的几个点。
4.5.1 帧长选择
帧长太短,统计不稳定;帧长太长,延迟大。我一般选 20-30ms,采样率 16kHz 时对应 320-480 个采样点。
帧移通常取帧长的一半或四分之一。比如帧长 400 点,帧移 160 点,这样相邻帧有 60% 的重叠,VAD 决策更平滑。
4.5.2 预加重
语音信号的高频成分能量低,容易被噪声淹没。做 VAD 前加一个预加重滤波器:
y[n] = x[n] - 0.97 * x[n-1]
这个操作会提升高频成分,让清音的过零率特征更明显。我习惯在 VAD 之前做预加重,效果立竿见影。
4.5.3 噪声估计
自适应阈值的关键是噪声估计。最简单的做法:取前 10 帧的平均能量作为初始噪声估计。之后用一阶递归更新:
noise_energy = 0.95 * noise_energy + 0.05 * current_energy
但注意,这个更新只在 VAD 判定为静音时才执行。如果语音帧也更新,噪声估计会被拉高,导致后续语音帧被误判为静音。
4.5.4 前后缀静音保留
VAD 切出来的语音段,开头和结尾往往会切掉一点点语音。因为能量从低到高有个爬升过程,阈值判断会滞后。
解决方案:在检测到的语音段前后各保留 5-10 帧(约 100-200ms)作为缓冲。这样虽然多保留了一点静音,但不会丢失语音的起始和结束部分。
我做过一个语音识别项目,去掉这个缓冲后,识别率掉了 3 个百分点。后来加回来,识别率又恢复了。
4.6 代码示例
最后给一个完整的 Python 实现,包含短时能量、过零率和基于状态机的 VAD:
import numpy as np
def extract_features(frame):
"""计算一帧的短时能量和过零率"""
# 短时能量
energy = np.sum(frame ** 2) / len(frame)
# 过零率
zcr = np.sum(np.abs(np.diff(np.sign(frame)))) / (2 * len(frame))
return energy, zcr
class StateMachineVAD:
def __init__(self, energy_thresh=0.02, zcr_thresh=0.1):
self.state = 'SILENCE'
self.counter = 0
self.energy_thresh = energy_thresh
self.zcr_thresh = zcr_thresh
self.onset_count = 3 # 连续 3 帧触发起始
self.offset_count = 5 # 连续 5 帧触发结束
def process_frame(self, frame):
energy, zcr = extract_features(frame)
if self.state == 'SILENCE':
if energy > self.energy_thresh and zcr > self.zcr_thresh:
self.counter += 1
if self.counter >= self.onset_count:
self.state = 'SPEECH'
self.counter = 0
else:
self.counter = 0
return False
elif self.state == 'SPEECH':
if energy < self.energy_thresh * 0.5:
self.counter += 1
if self.counter >= self.offset_count:
self.state = 'SILENCE'
self.counter = 0
else:
self.counter = 0
return True
return False
这段代码可以直接跑。注意阈值要根据实际场景调整——安静办公室和嘈杂马路的阈值差好几倍。
时域特征虽然简单,但它是语音处理的基石。短时能量告诉你"有没有声音",过零率告诉你"是什么类型的声音",VAD 把两者结合起来做决策。这三板斧用好了,很多语音问题就解决了一半。