migration to silero vad v6: supports onnx

This commit is contained in:
Quentin Fuxa 2025-10-23 23:52:00 +02:00
parent 818c9c37ca
commit e7b05b0138
9 changed files with 213 additions and 81 deletions

16
.gitignore vendored
View file

@ -54,21 +54,6 @@ coverage.xml
# Translations # Translations
*.mo *.mo
*.pot *.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder # PyBuilder
target/ target/
@ -139,3 +124,4 @@ launch.json
.DS_Store .DS_Store
test/* test/*
nllb-200-distilled-600M-ctranslate2/* nllb-200-distilled-600M-ctranslate2/*
*.mp3

View file

@ -50,8 +50,20 @@ Homepage = "https://github.com/QuentinFuxa/WhisperLiveKit"
whisperlivekit-server = "whisperlivekit.basic_server:main" whisperlivekit-server = "whisperlivekit.basic_server:main"
[tool.setuptools] [tool.setuptools]
packages = ["whisperlivekit", "whisperlivekit.diarization", "whisperlivekit.simul_whisper", "whisperlivekit.simul_whisper.whisper", "whisperlivekit.simul_whisper.whisper.assets", "whisperlivekit.simul_whisper.whisper.normalizers", "whisperlivekit.web", "whisperlivekit.whisper_streaming_custom", "whisperlivekit.translation"] packages = [
"whisperlivekit",
"whisperlivekit.diarization",
"whisperlivekit.simul_whisper",
"whisperlivekit.simul_whisper.whisper",
"whisperlivekit.simul_whisper.whisper.assets",
"whisperlivekit.simul_whisper.whisper.normalizers",
"whisperlivekit.web",
"whisperlivekit.whisper_streaming_custom",
"whisperlivekit.translation",
"whisperlivekit.vad_models"
]
[tool.setuptools.package-data] [tool.setuptools.package-data]
whisperlivekit = ["web/*.html", "web/*.css", "web/*.js", "web/src/*.svg"] whisperlivekit = ["web/*.html", "web/*.css", "web/*.js", "web/src/*.svg"]
"whisperlivekit.simul_whisper.whisper.assets" = ["*.tiktoken", "*.npz"] "whisperlivekit.simul_whisper.whisper.assets" = ["*.tiktoken", "*.npz"]
"whisperlivekit.vad_models" = ["*.jit", "*.onnx"]

View file

@ -33,6 +33,7 @@ class TranscriptionEngine:
"punctuation_split": False, "punctuation_split": False,
"target_language": "", "target_language": "",
"vac": True, "vac": True,
"vac_onnx": False,
"vac_chunk_size": 0.04, "vac_chunk_size": 0.04,
"log_level": "DEBUG", "log_level": "DEBUG",
"ssl_certfile": None, "ssl_certfile": None,
@ -75,8 +76,10 @@ class TranscriptionEngine:
self.vac_model = None self.vac_model = None
if self.args.vac: if self.args.vac:
import torch from whisperlivekit.silero_vad_iterator import load_silero_vad
self.vac_model, _ = torch.hub.load(repo_or_dir="snakers4/silero-vad", model="silero_vad") # Use ONNX if specified, otherwise use JIT (default)
use_onnx = kwargs.get('vac_onnx', False)
self.vac_model = load_silero_vad(onnx=use_onnx)
if self.args.transcription: if self.args.transcription:
if self.args.backend == "simulstreaming": if self.args.backend == "simulstreaming":

View file

@ -1,27 +1,182 @@
import torch import torch
import numpy as np
import warnings
from pathlib import Path
# This is copied from silero-vad's vad_utils.py: """
# https://github.com/snakers4/silero-vad/blob/f6b1294cb27590fb2452899df98fb234dfef1134/utils_vad.py#L340 Code is adapted from silero-vad v6: https://github.com/snakers4/silero-vad
# (except changed defaults) """
# Their licence is MIT, same as ours: https://github.com/snakers4/silero-vad/blob/f6b1294cb27590fb2452899df98fb234dfef1134/LICENSE def init_jit_model(model_path: str, device=torch.device('cpu')):
"""Load a JIT model from file."""
model = torch.jit.load(model_path, map_location=device)
model.eval()
return model
class OnnxWrapper():
"""ONNX Runtime wrapper for Silero VAD model."""
def __init__(self, path, force_onnx_cpu=False):
global np
import numpy as np
import onnxruntime
opts = onnxruntime.SessionOptions()
opts.inter_op_num_threads = 1
opts.intra_op_num_threads = 1
if force_onnx_cpu and 'CPUExecutionProvider' in onnxruntime.get_available_providers():
self.session = onnxruntime.InferenceSession(path, providers=['CPUExecutionProvider'], sess_options=opts)
else:
self.session = onnxruntime.InferenceSession(path, sess_options=opts)
self.reset_states()
if '16k' in path:
warnings.warn('This model support only 16000 sampling rate!')
self.sample_rates = [16000]
else:
self.sample_rates = [8000, 16000]
def _validate_input(self, x, sr: int):
if x.dim() == 1:
x = x.unsqueeze(0)
if x.dim() > 2:
raise ValueError(f"Too many dimensions for input audio chunk {x.dim()}")
if sr != 16000 and (sr % 16000 == 0):
step = sr // 16000
x = x[:,::step]
sr = 16000
if sr not in self.sample_rates:
raise ValueError(f"Supported sampling rates: {self.sample_rates} (or multiply of 16000)")
if sr / x.shape[1] > 31.25:
raise ValueError("Input audio chunk is too short")
return x, sr
def reset_states(self, batch_size=1):
self._state = torch.zeros((2, batch_size, 128)).float()
self._context = torch.zeros(0)
self._last_sr = 0
self._last_batch_size = 0
def __call__(self, x, sr: int):
x, sr = self._validate_input(x, sr)
num_samples = 512 if sr == 16000 else 256
if x.shape[-1] != num_samples:
raise ValueError(f"Provided number of samples is {x.shape[-1]} (Supported values: 256 for 8000 sample rate, 512 for 16000)")
batch_size = x.shape[0]
context_size = 64 if sr == 16000 else 32
if not self._last_batch_size:
self.reset_states(batch_size)
if (self._last_sr) and (self._last_sr != sr):
self.reset_states(batch_size)
if (self._last_batch_size) and (self._last_batch_size != batch_size):
self.reset_states(batch_size)
if not len(self._context):
self._context = torch.zeros(batch_size, context_size)
x = torch.cat([self._context, x], dim=1)
if sr in [8000, 16000]:
ort_inputs = {'input': x.numpy(), 'state': self._state.numpy(), 'sr': np.array(sr, dtype='int64')}
ort_outs = self.session.run(None, ort_inputs)
out, state = ort_outs
self._state = torch.from_numpy(state)
else:
raise ValueError()
self._context = x[..., -context_size:]
self._last_sr = sr
self._last_batch_size = batch_size
out = torch.from_numpy(out)
return out
def load_silero_vad(model_path: str = None, onnx: bool = False, opset_version: int = 16):
"""
Load Silero VAD model (JIT or ONNX).
Parameters
----------
model_path : str, optional
Path to model file. If None, uses default bundled model.
onnx : bool, default False
Whether to use ONNX runtime (requires onnxruntime package).
opset_version : int, default 16
ONNX opset version (15 or 16). Only used if onnx=True.
Returns
-------
model
Loaded VAD model (JIT or ONNX wrapper)
"""
available_ops = [15, 16]
if onnx and opset_version not in available_ops:
raise Exception(f'Available ONNX opset_version: {available_ops}')
if model_path is None:
current_dir = Path(__file__).parent
data_dir = current_dir / 'vad_models'
if onnx:
if opset_version == 16:
model_name = 'silero_vad.onnx'
else:
model_name = f'silero_vad_16k_op{opset_version}.onnx'
else:
model_name = 'silero_vad.jit'
model_path = data_dir / model_name
if not model_path.exists():
raise FileNotFoundError(
f"Model file not found: {model_path}\n"
f"Please ensure the whisperlivekit/vad_models/ directory contains the model files."
)
else:
model_path = Path(model_path)
if onnx:
try:
model = OnnxWrapper(str(model_path), force_onnx_cpu=True)
except ImportError:
raise ImportError(
"ONNX runtime not available. Install with: pip install onnxruntime\n"
"Or use JIT model by setting onnx=False"
)
else:
model = init_jit_model(str(model_path))
return model
class VADIterator: class VADIterator:
def __init__( """
self, Voice Activity Detection iterator for streaming audio.
model,
threshold: float = 0.5, This is the Silero VAD v6 implementation.
sampling_rate: int = 16000, """
min_silence_duration_ms: int = 500, # makes sense on one recording that I checked
speech_pad_ms: int = 100, # same def __init__(self,
): model,
threshold: float = 0.5,
sampling_rate: int = 16000,
min_silence_duration_ms: int = 100,
speech_pad_ms: int = 30
):
""" """
Class for stream imitation Class for stream imitation
Parameters Parameters
---------- ----------
model: preloaded .jit silero VAD model model: preloaded .jit/.onnx silero VAD model
threshold: float (default - 0.5) threshold: float (default - 0.5)
Speech threshold. Silero VAD outputs speech probabilities for each audio chunk, probabilities ABOVE this value are considered as SPEECH. Speech threshold. Silero VAD outputs speech probabilities for each audio chunk, probabilities ABOVE this value are considered as SPEECH.
@ -42,9 +197,7 @@ class VADIterator:
self.sampling_rate = sampling_rate self.sampling_rate = sampling_rate
if sampling_rate not in [8000, 16000]: if sampling_rate not in [8000, 16000]:
raise ValueError( raise ValueError('VADIterator does not support sampling rates other than [8000, 16000]')
"VADIterator does not support sampling rates other than [8000, 16000]"
)
self.min_silence_samples = sampling_rate * min_silence_duration_ms / 1000 self.min_silence_samples = sampling_rate * min_silence_duration_ms / 1000
self.speech_pad_samples = sampling_rate * speech_pad_ms / 1000 self.speech_pad_samples = sampling_rate * speech_pad_ms / 1000
@ -57,13 +210,17 @@ class VADIterator:
self.temp_end = 0 self.temp_end = 0
self.current_sample = 0 self.current_sample = 0
def __call__(self, x, return_seconds=False): @torch.no_grad()
def __call__(self, x, return_seconds=False, time_resolution: int = 1):
""" """
x: torch.Tensor x: torch.Tensor
audio chunk (see examples in repo) audio chunk (see examples in repo)
return_seconds: bool (default - False) return_seconds: bool (default - False)
whether return timestamps in seconds (default - samples) whether return timestamps in seconds (default - samples)
time_resolution: int (default - 1)
time resolution of speech coordinates when requested as seconds
""" """
if not torch.is_tensor(x): if not torch.is_tensor(x):
@ -82,14 +239,8 @@ class VADIterator:
if (speech_prob >= self.threshold) and not self.triggered: if (speech_prob >= self.threshold) and not self.triggered:
self.triggered = True self.triggered = True
speech_start = self.current_sample - self.speech_pad_samples speech_start = max(0, self.current_sample - self.speech_pad_samples - window_size_samples)
return { return {'start': int(speech_start) if not return_seconds else round(speech_start / self.sampling_rate, time_resolution)}
"start": (
int(speech_start)
if not return_seconds
else round(speech_start / self.sampling_rate, 1)
)
}
if (speech_prob < self.threshold - 0.15) and self.triggered: if (speech_prob < self.threshold - 0.15) and self.triggered:
if not self.temp_end: if not self.temp_end:
@ -97,30 +248,17 @@ class VADIterator:
if self.current_sample - self.temp_end < self.min_silence_samples: if self.current_sample - self.temp_end < self.min_silence_samples:
return None return None
else: else:
speech_end = self.temp_end + self.speech_pad_samples speech_end = self.temp_end + self.speech_pad_samples - window_size_samples
self.temp_end = 0 self.temp_end = 0
self.triggered = False self.triggered = False
return { return {'end': int(speech_end) if not return_seconds else round(speech_end / self.sampling_rate, time_resolution)}
"end": (
int(speech_end)
if not return_seconds
else round(speech_end / self.sampling_rate, 1)
)
}
return None return None
#######################
# because Silero now requires exactly 512-sized audio chunks
import numpy as np
class FixedVADIterator(VADIterator): class FixedVADIterator(VADIterator):
"""It fixes VADIterator by allowing to process any audio length, not only exactly 512 frames at once. """
If audio to be processed at once is long and multiple voiced segments detected, Fixed VAD Iterator that handles variable-length audio chunks, not only exactly 512 frames at once.
then __call__ returns the start of the first segment, and end (or middle, which means no end) of the last segment.
""" """
def reset_states(self): def reset_states(self):
@ -137,27 +275,20 @@ class FixedVADIterator(VADIterator):
ret = r ret = r
elif r is not None: elif r is not None:
if "end" in r: if "end" in r:
ret["end"] = r["end"] # the latter end ret["end"] = r["end"]
if "start" in r and "end" in ret: # there is an earlier start. if "start" in r and "end" in ret:
# Remove end, merging this segment with the previous one.
del ret["end"] del ret["end"]
return ret if ret != {} else None return ret if ret != {} else None
if __name__ == "__main__": if __name__ == "__main__":
# test/demonstrate the need for FixedVADIterator: model = load_silero_vad(onnx=False)
vad = FixedVADIterator(model)
import torch audio_buffer = np.array([0] * 512, dtype=np.float32)
result = vad(audio_buffer)
print(f" 512 samples: {result}")
model, _ = torch.hub.load(repo_or_dir="snakers4/silero-vad", model="silero_vad") # test with 511 samples
vac = FixedVADIterator(model) audio_buffer = np.array([0] * 511, dtype=np.float32)
# vac = VADIterator(model) # the second case crashes with this result = vad(audio_buffer)
# this works: for both
audio_buffer = np.array([0] * (512), dtype=np.float32)
vac(audio_buffer)
# this crashes on the non FixedVADIterator with
# ops.prim.RaiseException("Input audio chunk is too short", "builtins.ValueError")
audio_buffer = np.array([0] * (512 - 1), dtype=np.float32)
vac(audio_buffer)

View file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.