2. 基于过零率的VAD
任务目标
本案例使用过零率(Zero Crossing Rate, ZCR)实现一个轻量级VAD:对输入的音频帧计算短时过零率,根据阈值判断该帧是否为语音活动。同时利用清音(unvoiced)过零率高、浊音(voiced)过零率低的特性,在检测到语音后进一步区分当前帧属于清音还是浊音。
环境与依赖
需要 numpy 和 wave(Python标准库)。若需读取非WAV格式,请自行用 soundfile 等库转换,本案例以16kHz单声道WAV为输入。
核心实现
import numpy as np
import wave
def read_wav(path):
"""读取16-bit PCM WAV,返回(采样率, 单声道float数组,范围[-1,1])"""
with wave.open(path, 'rb') as wf:
sr = wf.getframerate()
n_ch = wf.getnchannels()
sw = wf.getsampwidth()
n_frames = wf.getnframes()
raw = wf.readframes(n_frames)
data = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
if n_ch > 1:
data = data.reshape(-1, n_ch).mean(axis=1)
return sr, data
def frame_signal(x, sr, win_len=0.025, hop_len=0.010):
"""分帧:win_len/hop_len单位为秒,返回(帧数, 帧长)数组"""
win = int(sr * win_len)
hop = int(sr * hop_len)
n_frames = 1 + (len(x) - win) // hop
return np.stack([x[i*hop : i*hop+win] for i in range(n_frames)])
def zcr_per_frame(frames):
"""计算每帧过零率:符号变化的次数 / (帧长-1)"""
signs = np.signbit(frames)
# 相邻样本符号不同则为过零
changes = np.abs(np.diff(signs.astype(np.int8), axis=1)).sum(axis=1)
return changes / (frames.shape[1] - 1)
def vad_by_zcr(frames, zcr, voiced_th=0.03, unvoiced_th=0.10, energy_th=1e-3):
"""
基于ZCR的VAD + 清/浊音分类
返回: labels数组, 0=静音, 1=浊音, 2=清音
"""
# 先计算帧能量,低能量帧直接判静音,避免噪声误触发
energy = (frames ** 2).mean(axis=1)
labels = np.zeros(len(frames), dtype=np.int8)
for i in range(len(frames)):
if energy[i] < energy_th:
continue # 静音
if zcr[i] < voiced_th:
labels[i] = 1 # 浊音:低过零率
elif zcr[i] > unvoiced_th:
labels[i] = 2 # 清音:高过零率
# 介于两者之间视为弱语音,归为浊音(保守处理)
else:
labels[i] = 1
return labels
def smooth_labels(labels, min_dur=3):
"""简单中值平滑,去除小于min_dur帧的毛刺"""
if len(labels) < min_dur:
return labels
smoothed = labels.copy()
half = min_dur // 2
for i in range(len(labels)):
seg = labels[max(0, i-half) : i+half+1]
vals, counts = np.unique(seg, return_counts=True)
smoothed[i] = vals[np.argmax(counts)]
return smoothed
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("用法: python zcr_vad.py <audio.wav>")
sys.exit(1)
sr, x = read_wav(sys.argv[1])
frames = frame_signal(x, sr)
zcr = zcr_per_frame(frames)
labels = vad_by_zcr(frames, zcr)
labels = smooth_labels(labels)
# 输出每帧标签(帧长25ms,帧移10ms)
for i, lab in enumerate(labels):
t_start = i * 0.010
t_end = t_start + 0.025
name = {0: "静音", 1: "浊音", 2: "清音"}[lab]
print(f"帧{i:4d} [{t_start:6.2f}s-{t_end:6.2f}s] ZCR={zcr[i]:.3f} → {name}")
运行方式
# 生成测试音频(1秒浊音/清音/静音交替)
python -c "
import numpy as np, wave
sr = 16000
t = np.arange(sr)/sr
# 浊音:100Hz 低频 + 谐波
voiced = 0.5*np.sin(2*np.pi*100*t) + 0.3*np.sin(2*np.pi*200*t)
# 清音:白噪声
unvoiced = np.random.randn(sr)*0.3
# 拼接:0.5s浊音 + 0.5s清音 + 0.5s静音
seg = np.concatenate([voiced[:sr//2], unvoiced[:sr//2], np.zeros(sr//2)])
data = (seg*32767).astype(np.int16)
with wave.open('test_zcr.wav','wb') as wf:
wf.setnchannels(1); wf.setsampwidth(2); wf.setframerate(sr)
wf.writeframes(data.tobytes())
print('已生成 test_zcr.wav')
"
# 运行VAD
python zcr_vad.py test_zcr.wav
验证方法与判定标准
观察输出标签序列:
- 0~0.5秒区间(浊音段)应输出浊音,ZCR通常低于0.03;
- 0.5~1.0秒区间(清音段)应输出清音,ZCR通常高于0.10;
- 1.0~1.5秒区间(静音段)应输出静音。
若三类标签与上述时间段基本吻合(允许边界处有1~2帧平滑过渡),则判定VAD与清/浊音区分功能通过。注意:实际ZCR值受噪声和幅度影响,若测试音频幅度过小,能量阈值 energy_th=1e-3 可能将语音误判为静音,此时可适当降低该阈值。
关键参数说明
- 帧长25ms、帧移10ms:标准语音分析配置,兼顾时间分辨率与频率分辨率。
- voiced_th=0.03 / unvoiced_th=0.10:浊音周期性强、过零率低;清音类似噪声、过零率高。阈值可根据实际音频微调。
- energy_th=1e-3:防止静音段噪声引起过零率波动导致误判。
- 平滑窗口min_dur=3:消除单帧毛刺,使输出更稳定。
常见问题
- 浊音被误判为清音:若音频含较强高频噪声,浊音ZCR会升高。可提高
voiced_th或先做低通滤波。 - 静音段被误判为语音:背景噪声导致ZCR和能量超标。可提高
energy_th或增加噪声底估计。 - 清音与静音难区分:清音能量通常低于浊音但高于静音,若噪声接近清音能量,需结合频谱特征进一步区分,本案例仅用ZCR+能量已覆盖基本场景。