Initial commit: FunASR Speech Recognition Toolkit
Update API Documentation / build-api-docs (push) Has been cancelled
Update API Documentation / build-api-docs (push) Has been cancelled
Add complete FunASR codebase including models, runtime, and documentation.
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
#!/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 tensor,16kHz)。
|
||||
可以是任意长度,内部按 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 tensor,16kHz)。
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user