2. 基于WebRTC VAD的语音活动检测集成

任务目标

在关键词唤醒(KWS)流水线前端集成WebRTC VAD(语音活动检测),对音频帧进行实时静音/语音判别,仅将包含语音的帧送入后续唤醒引擎,从而过滤静音段、降低误唤醒率。本案例实现一个可独立运行的Python模块,读取PCM音频文件,输出每帧的VAD判定结果及过滤后的语音帧索引。

环境依赖

  • Python 3.8+
  • webrtcvad(pip 安装:pip install webrtcvad)
  • 仅需标准库 wave、struct 处理音频读取

核心实现

以下代码实现完整闭环:读取16kHz单声道16-bit PCM文件,按20ms帧长(320样本/帧)切分,逐帧调用VAD判定,输出语音帧索引列表,并支持将过滤后的语音段拼接保存为新PCM文件。

import wave
import struct
import webrtcvad
from typing import List, Tuple

class WebRTCVADProcessor:
    """基于WebRTC VAD的语音活动检测器,用于KWS前端静音过滤。"""
    
    def __init__(self, aggressiveness: int = 2, sample_rate: int = 16000, frame_ms: int = 20):
        """
        Args:
            aggressiveness: VAD激进程度 0~3,值越大越激进(更少语音被判为语音)
            sample_rate: 音频采样率,仅支持8000/16000/32000/48000
            frame_ms: 帧长,仅支持10/20/30ms
        """
        if sample_rate not in (8000, 16000, 32000, 48000):
            raise ValueError("sample_rate must be 8000/16000/32000/48000")
        if frame_ms not in (10, 20, 30):
            raise ValueError("frame_ms must be 10/20/30")
        
        self.vad = webrtcvad.Vad(aggressiveness)
        self.sample_rate = sample_rate
        self.frame_len = int(sample_rate * frame_ms / 1000)  # 每帧样本数
        self.frame_bytes = self.frame_len * 2  # 16-bit = 2字节
    
    def read_pcm(self, filepath: str) -> Tuple[List[int], int]:
        """读取WAV文件,返回单声道PCM样本列表和采样率。"""
        with wave.open(filepath, 'rb') as wf:
            assert wf.getnchannels() == 1, "仅支持单声道"
            assert wf.getsampwidth() == 2, "仅支持16-bit"
            assert wf.getframerate() == self.sample_rate, f"采样率需为{self.sample_rate}"
            raw = wf.readframes(wf.getnframes())
        samples = struct.unpack(f'<{len(raw)//2}h', raw)
        return list(samples), wf.getframerate()
    
    def frame_generator(self, samples: List[int]):
        """将样本列表按帧长切分为字节块。"""
        n_frames = len(samples) // self.frame_len
        for i in range(n_frames):
            start = i * self.frame_len
            end = start + self.frame_len
            frame = samples[start:end]
            # 转为16-bit little-endian字节
            yield struct.pack(f'<{self.frame_len}h', *frame)
    
    def detect_speech_frames(self, samples: List[int]) -> List[int]:
        """
        对每个音频帧执行VAD判定。
        Returns:
            语音帧索引列表(0-based)
        """
        speech_frames = []
        for idx, frame_bytes in enumerate(self.frame_generator(samples)):
            is_speech = self.vad.is_speech(frame_bytes, self.sample_rate)
            if is_speech:
                speech_frames.append(idx)
        return speech_frames
    
    def filter_silence(self, input_wav: str, output_wav: str = None) -> Tuple[List[int], List[int]]:
        """
        主处理入口:读取WAV,检测语音帧,可选保存过滤后的音频。
        Returns:
            (语音帧索引列表, 全部样本列表)
        """
        samples, _ = self.read_pcm(input_wav)
        speech_idx = self.detect_speech_frames(samples)
        
        if output_wav:
            # 拼接所有语音帧为连续音频
            speech_samples = []
            for idx in speech_idx:
                start = idx * self.frame_len
                end = start + self.frame_len
                speech_samples.extend(samples[start:end])
            
            with wave.open(output_wav, 'wb') as wf:
                wf.setnchannels(1)
                wf.setsampwidth(2)
                wf.setframerate(self.sample_rate)
                wf.writeframes(struct.pack(f'<{len(speech_samples)}h', *speech_samples))
        
        return speech_idx, samples

# 最小调用入口
if __name__ == "__main__":
    import sys
    
    if len(sys.argv) < 2:
        print("用法: python vad_kws.py <input.wav> [output.wav]")
        print("示例: python vad_kws.py test.wav filtered.wav")
        sys.exit(1)
    
    input_path = sys.argv[1]
    output_path = sys.argv[2] if len(sys.argv) > 2 else None
    
    processor = WebRTCVADProcessor(aggressiveness=2)
    speech_idx, _ = processor.filter_silence(input_path, output_path)
    
    # 输出统计信息
    total_frames = len(speech_idx)  # 实际总帧数需从文件计算
    # 重新计算总帧数以获得准确统计
    samples, _ = processor.read_pcm(input_path)
    total_frames = len(samples) // processor.frame_len
    speech_ratio = len(speech_idx) / total_frames * 100 if total_frames > 0 else 0
    
    print(f"总帧数: {total_frames}")
    print(f"语音帧数: {len(speech_idx)}")
    print(f"语音占比: {speech_ratio:.1f}%")
    print(f"静音帧数: {total_frames - len(speech_idx)}")
    if output_path:
        print(f"过滤后音频已保存: {output_path}")

运行与验证

  1. 准备测试音频:准备一段包含语音和静音的16kHz单声道16-bit WAV文件(如 test.wav),时长建议5~10秒。
  2. 运行检测:
python vad_kws.py test.wav filtered.wav
  1. 验证通过条件:
  • 程序正常输出总帧数、语音帧数、语音占比,无异常报错;
  • 语音占比应在合理范围(如30%~80%),若测试文件包含明显静音段,语音占比应显著小于100%;
  • 生成的 filtered.wav 可正常播放,听感上首尾静音被去除,语音内容连续完整;
  • 对比原始文件,过滤后文件时长明显缩短(若原文件含静音段)。

KWS集成说明

在实际KWS系统中,将本模块的 detect_speech_frames 逻辑嵌入实时音频流处理:每次从麦克风获取20ms音频帧,调用 vad.is_speech() 判定,仅当返回True时才将该帧送入唤醒引擎的特征提取与匹配模块。激进程度参数 aggressiveness 建议在真实唤醒场景中调参:值越大,对静音的过滤越强,但可能误切语音起始段;值越小则越保守,适合噪声环境。推荐初始值2,再根据误唤醒率与召回率平衡调整。