Files
2026-07-13 13:25:10 +08:00

223 lines
7.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""DynamicStreamingVAD — 动态阈值流式 VAD 封装。
在 fsmn-vad 基础上,根据当前语音段的累积时长动态调整静音切分阈值:
短句等待更长静音(避免切碎),长句快速切分(避免堆积)。
支持流式(逐帧喂入)和非流式(一次性处理完整音频)两种调用方式。
Usage (流式):
from funasr import AutoModel
from funasr.models.fsmn_vad_streaming.dynamic_vad import DynamicStreamingVAD
vad_model = AutoModel(model="fsmn-vad", device="cuda:0")
vad = DynamicStreamingVAD(vad_model)
for audio_chunk in audio_stream:
segments = vad.feed(audio_chunk)
for seg in segments:
print(f"Speech: {seg[0]}-{seg[1]}ms")
# 结束时
final_segments = vad.finalize()
Usage (非流式):
segments = vad.process(full_audio_tensor)
for seg in segments:
print(f"Speech: {seg[0]}-{seg[1]}ms")
"""
from typing import List, Optional, Tuple
import torch
import numpy as np
# 默认动态阈值配置:(累积时长上限ms, 静音阈值ms)
DEFAULT_SILENCE_SCHEDULE = [
(5000, 2000),
(10000, 1500),
(15000, 1000),
(30000, 800),
(45000, 400),
(float('inf'), 100),
]
class DynamicStreamingVAD:
"""动态阈值流式 VAD。
在 fsmn-vad 的流式推理基础上,根据当前语音段已累积的时长
动态调整静音切分阈值,实现「短句不切碎、长句快切分」。
Args:
vad_model: FunASR AutoModel 加载的 fsmn-vad 模型实例。
chunk_size_ms: 每次喂入 VAD 的 chunk 大小(毫秒),默认 60。
speech_noise_thres: 语音/噪声判别阈值,默认 0.5。
speech_to_sil_thres_ms: 语音转静音的基础时间(毫秒),默认 150。
silence_schedule: 动态阈值配置表,格式为
[(累积时长上限ms, 对应的静音阈值ms), ...]。
当累积时长 <= 上限时,使用对应的静音阈值。
默认值适合实时对话场景。设为 None 禁用动态调整(使用固定阈值)。
sample_rate: 采样率,默认 16000。
Example:
# 自定义阈值:更激进的切分
vad = DynamicStreamingVAD(
vad_model,
silence_schedule=[
(3000, 1500),
(8000, 800),
(15000, 400),
(float('inf'), 200),
],
)
"""
def __init__(
self,
vad_model,
chunk_size_ms: int = 60,
speech_noise_thres: float = 0.5,
speech_to_sil_thres_ms: int = 150,
silence_schedule: Optional[List[Tuple[float, int]]] = None,
sample_rate: int = 16000,
):
self.model = vad_model
self.chunk_size_ms = chunk_size_ms
self.speech_noise_thres = speech_noise_thres
self.speech_to_sil_thres_ms = speech_to_sil_thres_ms
self.silence_schedule = silence_schedule if silence_schedule is not None else DEFAULT_SILENCE_SCHEDULE
self.sample_rate = sample_rate
self.cache = {}
self.confirmed_segments: List[List[int]] = []
self.current_speech_start: Optional[int] = None
self.accumulated_since_cut_ms: int = 0
def _get_silence_threshold(self) -> int:
"""根据当前累积时长,从 schedule 中查询静音阈值。"""
for limit_ms, silence_ms in self.silence_schedule:
if self.accumulated_since_cut_ms <= limit_ms:
return silence_ms
return self.silence_schedule[-1][1]
def _apply_dynamic_threshold(self):
"""将动态阈值应用到 VAD 内部 cache。"""
if "stats" not in self.cache:
return
stats = self.cache["stats"]
stats.speech_noise_thres = self.speech_noise_thres
desired_silence_ms = self._get_silence_threshold()
stats.max_end_sil_frame_cnt_thresh = max(desired_silence_ms - self.speech_to_sil_thres_ms, 0)
def feed(self, audio_chunk: torch.Tensor, is_final: bool = False) -> List[List[int]]:
"""喂入一段音频,返回新确认的语音段。
Args:
audio_chunk: 音频数据(float32 tensor16kHz)。
可以是任意长度,内部按 chunk_size_ms 处理。
is_final: 是否为最后一段音频。设为 True 时会强制结束当前语音段。
Returns:
新确认的语音段列表,每段为 [start_ms, end_ms]。
仅在检测到语音结束时返回非空列表。
"""
if audio_chunk.dim() > 1:
audio_chunk = audio_chunk.squeeze()
chunk_samples = len(audio_chunk)
self.accumulated_since_cut_ms += int(chunk_samples * 1000 / self.sample_rate)
self._apply_dynamic_threshold()
res = self.model.generate(
input=[audio_chunk], cache=self.cache,
is_final=is_final, chunk_size=self.chunk_size_ms,
)
signals = res[0].get("value", [])
new_confirmed = []
for sig in signals:
if sig[0] >= 0 and sig[1] == -1:
self.current_speech_start = sig[0]
elif sig[0] == -1 and sig[1] >= 0:
start = self.current_speech_start if self.current_speech_start is not None else 0
seg = [start, sig[1]]
self.confirmed_segments.append(seg)
new_confirmed.append(seg)
self.current_speech_start = None
self.accumulated_since_cut_ms = 0
elif sig[0] >= 0 and sig[1] >= 0:
self.confirmed_segments.append(sig)
new_confirmed.append(sig)
self.current_speech_start = None
self.accumulated_since_cut_ms = 0
return new_confirmed
def finalize(self) -> List[List[int]]:
"""结束流式处理,返回最后可能未结束的语音段。
调用此方法后,VAD 状态会被重置。
如果当前有正在进行的语音段,会被强制结束。
Returns:
最后确认的语音段列表。
"""
# Feed empty with is_final=True to flush
empty = torch.zeros(int(self.sample_rate * 0.01), dtype=torch.float32)
return self.feed(empty, is_final=True)
def process(self, audio: torch.Tensor) -> List[List[int]]:
"""非流式接口:一次性处理完整音频,返回所有语音段。
Args:
audio: 完整音频(float32 tensor16kHz)。
Returns:
所有检测到的语音段 [[start_ms, end_ms], ...]。
"""
self.reset()
if isinstance(audio, np.ndarray):
audio = torch.from_numpy(audio).float()
if audio.dim() > 1:
audio = audio.squeeze()
# 分 chunk 喂入
chunk_samples = int(self.sample_rate * self.chunk_size_ms / 1000)
total = len(audio)
all_segments = []
for i in range(0, total, chunk_samples):
chunk = audio[i:i + chunk_samples]
is_last = (i + chunk_samples >= total)
segs = self.feed(chunk, is_final=is_last)
all_segments.extend(segs)
return all_segments
@property
def is_speaking(self) -> bool:
"""当前是否在语音状态中。"""
return self.current_speech_start is not None
@property
def current_duration_ms(self) -> int:
"""当前段已累积的时长(毫秒)。"""
return self.accumulated_since_cut_ms
@property
def current_threshold_ms(self) -> int:
"""当前使用的静音阈值(毫秒)。"""
return self._get_silence_threshold()
def reset(self):
"""重置所有状态,开始新一轮检测。"""
self.cache = {}
self.confirmed_segments = []
self.current_speech_start = None
self.accumulated_since_cut_ms = 0