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+能量已覆盖基本场景。