realtimevad 0.0.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- RealtimeVAD/__init__.py +8 -0
- RealtimeVAD/assets/LICENSE-silero-vad.txt +23 -0
- RealtimeVAD/assets/silero_vad_v6.2.1.onnx +0 -0
- RealtimeVAD/iterator.py +84 -0
- RealtimeVAD/silero.py +76 -0
- RealtimeVAD/vad.py +436 -0
- realtimevad-0.0.4.dist-info/METADATA +174 -0
- realtimevad-0.0.4.dist-info/RECORD +11 -0
- realtimevad-0.0.4.dist-info/WHEEL +5 -0
- realtimevad-0.0.4.dist-info/licenses/LICENSE +21 -0
- realtimevad-0.0.4.dist-info/top_level.txt +1 -0
RealtimeVAD/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
silero_vad_v6.2.1.onnx comes from Silero VAD (https://github.com/snakers4/silero-vad).
|
|
2
|
+
|
|
3
|
+
MIT License
|
|
4
|
+
|
|
5
|
+
Copyright (c) 2020-present Silero Team
|
|
6
|
+
|
|
7
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
8
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
9
|
+
in the Software without restriction, including without limitation the rights
|
|
10
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
11
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
12
|
+
furnished to do so, subject to the following conditions:
|
|
13
|
+
|
|
14
|
+
The above copyright notice and this permission notice shall be included in all
|
|
15
|
+
copies or substantial portions of the Software.
|
|
16
|
+
|
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
18
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
19
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
20
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
21
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
22
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
23
|
+
SOFTWARE.
|
|
Binary file
|
RealtimeVAD/iterator.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Drop-in for ``silero_vad.VADIterator``: same constructor, call and results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from .silero import SAMPLE_RATE
|
|
10
|
+
from .vad import FRAME_S, VAD
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _SileroModel:
|
|
14
|
+
"""Adapts a model from ``silero_vad.load_silero_vad()`` to a frame -> probability call."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, model) -> None:
|
|
17
|
+
self._model = model
|
|
18
|
+
try:
|
|
19
|
+
import torch
|
|
20
|
+
|
|
21
|
+
self._tensor = torch.from_numpy
|
|
22
|
+
except ImportError:
|
|
23
|
+
self._tensor = lambda a: a
|
|
24
|
+
|
|
25
|
+
def __call__(self, frame: np.ndarray) -> float:
|
|
26
|
+
p = self._model(self._tensor(np.ascontiguousarray(frame)), SAMPLE_RATE)
|
|
27
|
+
return float(p.item() if hasattr(p, "item") else p)
|
|
28
|
+
|
|
29
|
+
def reset(self) -> None:
|
|
30
|
+
self._model.reset_states()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class VADIterator:
|
|
34
|
+
"""Like Silero's ``VADIterator``, but ``end`` comes as soon as the audio goes quiet.
|
|
35
|
+
|
|
36
|
+
``model`` may be a Silero model (used as given) or None (the bundled one).
|
|
37
|
+
Extra keyword arguments go to :class:`VAD`. Chunks may have any size.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
model=None,
|
|
43
|
+
threshold: float = 0.5,
|
|
44
|
+
sampling_rate: int = 16000,
|
|
45
|
+
min_silence_duration_ms: int = 100,
|
|
46
|
+
speech_pad_ms: int = 30,
|
|
47
|
+
neg_threshold: Optional[float] = None,
|
|
48
|
+
**options,
|
|
49
|
+
) -> None:
|
|
50
|
+
if sampling_rate != SAMPLE_RATE:
|
|
51
|
+
raise ValueError("RealtimeVAD supports 16000 Hz only")
|
|
52
|
+
if neg_threshold is None:
|
|
53
|
+
neg_threshold = max(threshold - 0.15, 0.01)
|
|
54
|
+
self.sampling_rate = sampling_rate
|
|
55
|
+
self.speech_pad_samples = sampling_rate * speech_pad_ms / 1000
|
|
56
|
+
self.vad = VAD(
|
|
57
|
+
start_threshold=threshold,
|
|
58
|
+
continue_threshold=neg_threshold,
|
|
59
|
+
start_s=FRAME_S, # Silero starts on one frame
|
|
60
|
+
end_s=min_silence_duration_ms / 1000,
|
|
61
|
+
preroll_s=speech_pad_ms / 1000,
|
|
62
|
+
probability=None if model is None else _SileroModel(model),
|
|
63
|
+
**options,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def triggered(self) -> bool:
|
|
68
|
+
return self.vad.in_speech
|
|
69
|
+
|
|
70
|
+
def reset_states(self) -> None:
|
|
71
|
+
self.vad.reset()
|
|
72
|
+
|
|
73
|
+
def __call__(self, x, return_seconds: bool = False, time_resolution: int = 1) -> Optional[dict]:
|
|
74
|
+
result = {}
|
|
75
|
+
for event in self.vad.feed(np.asarray(x)):
|
|
76
|
+
if event.kind == "start": # Silero: start of the first speech frame, minus the pad
|
|
77
|
+
sample = (event.time - FRAME_S) * self.sampling_rate - self.speech_pad_samples
|
|
78
|
+
elif event.kind == "end": # Silero: where speech stopped, plus the pad
|
|
79
|
+
sample = event.segment.speech_end * self.sampling_rate + self.speech_pad_samples
|
|
80
|
+
else:
|
|
81
|
+
continue
|
|
82
|
+
sample = max(0, int(round(sample)))
|
|
83
|
+
result[event.kind] = round(sample / self.sampling_rate, time_resolution) if return_seconds else sample
|
|
84
|
+
return result or None
|
RealtimeVAD/silero.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Stateful Silero v6 ONNX inference on 512-sample, 16 kHz frames."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
SAMPLE_RATE = 16_000
|
|
11
|
+
FRAME = 512
|
|
12
|
+
MODEL = Path(__file__).parent / "assets" / "silero_vad_v6.2.1.onnx"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Silero:
|
|
16
|
+
"""Silero v6 with recovery from its "deaf" state.
|
|
17
|
+
|
|
18
|
+
On a long stream the recurrent state can drift into an attractor where the
|
|
19
|
+
probability stays near 0 even through loud speech (low babble, near-silent
|
|
20
|
+
pauses). After ``restore_s`` of p < 0.1 the state is put back to where the
|
|
21
|
+
model last heard confident speech (p >= 0.9), or zeroed before any. A zero
|
|
22
|
+
state is the worse restart point: it scores the next utterance lower and
|
|
23
|
+
later. Costs no extra inference; 0 disables it.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, model: str | Path = MODEL, threads: int = 1, restore_s: float = 1.0) -> None:
|
|
27
|
+
import onnxruntime as ort
|
|
28
|
+
|
|
29
|
+
options = ort.SessionOptions()
|
|
30
|
+
options.intra_op_num_threads = threads
|
|
31
|
+
options.inter_op_num_threads = 1
|
|
32
|
+
options.log_severity_level = 3
|
|
33
|
+
self._session = ort.InferenceSession(
|
|
34
|
+
str(model), sess_options=options, providers=["CPUExecutionProvider"]
|
|
35
|
+
)
|
|
36
|
+
self._sr = np.asarray(SAMPLE_RATE, dtype=np.int64)
|
|
37
|
+
self._restore_frames = math.ceil(restore_s * SAMPLE_RATE / FRAME - 1e-9) if restore_s > 0 else 0
|
|
38
|
+
self.restores = 0
|
|
39
|
+
self.reset()
|
|
40
|
+
|
|
41
|
+
def reset(self) -> None:
|
|
42
|
+
"""Start a new stream: zero state and context, forget the speech snapshot."""
|
|
43
|
+
self._context = np.zeros((1, 64), dtype=np.float32)
|
|
44
|
+
self._state = np.zeros((2, 1, 128), dtype=np.float32)
|
|
45
|
+
self._snapshot = None
|
|
46
|
+
self._quiet = 0
|
|
47
|
+
|
|
48
|
+
def __call__(self, frame: np.ndarray) -> float:
|
|
49
|
+
audio = np.asarray(frame, dtype=np.float32).reshape(1, FRAME)
|
|
50
|
+
p, self._state = self._session.run(
|
|
51
|
+
None,
|
|
52
|
+
{"input": np.concatenate((self._context, audio), axis=1), "sr": self._sr, "state": self._state},
|
|
53
|
+
)
|
|
54
|
+
self._context = audio[:, -64:].copy()
|
|
55
|
+
p = float(np.asarray(p).reshape(-1)[0])
|
|
56
|
+
if self._restore_frames: # standalone rule; inside VAD, VAD decides both (see vad.py)
|
|
57
|
+
if p >= 0.9:
|
|
58
|
+
self.snapshot()
|
|
59
|
+
self._quiet = self._quiet + 1 if p < 0.1 else 0
|
|
60
|
+
if self._quiet >= self._restore_frames:
|
|
61
|
+
self.restore()
|
|
62
|
+
return p
|
|
63
|
+
|
|
64
|
+
def snapshot(self) -> None:
|
|
65
|
+
"""Remember the current state as the restore point (fresh arrays each frame)."""
|
|
66
|
+
self._snapshot = (self._state, self._context)
|
|
67
|
+
|
|
68
|
+
def restore(self) -> None:
|
|
69
|
+
"""Put the state back to the last confident speech (zeros before any); affects the next frame."""
|
|
70
|
+
self._quiet = 0
|
|
71
|
+
self.restores += 1
|
|
72
|
+
if self._snapshot is None:
|
|
73
|
+
self._context = np.zeros((1, 64), dtype=np.float32)
|
|
74
|
+
self._state = np.zeros((2, 1, 128), dtype=np.float32)
|
|
75
|
+
else:
|
|
76
|
+
self._state, self._context = (a.copy() for a in self._snapshot)
|
RealtimeVAD/vad.py
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
"""Speech start/end detection. Silero decides speech; the end races Silero's
|
|
2
|
+
silence count against a 10 ms near-silence clock (see SPEC.md)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import math
|
|
7
|
+
import time
|
|
8
|
+
from collections import deque
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Callable, Optional
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from .silero import FRAME, SAMPLE_RATE
|
|
15
|
+
|
|
16
|
+
BLOCK = 160 # 10 ms quiet-clock block
|
|
17
|
+
FRAME_S = FRAME / SAMPLE_RATE
|
|
18
|
+
EPS = 1e-9
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Segment:
|
|
23
|
+
audio: np.ndarray # float32, 16 kHz, from just before the start up to the end
|
|
24
|
+
start: float
|
|
25
|
+
end: float # stream time the end was decided
|
|
26
|
+
speech_end: float # when the speech actually stopped
|
|
27
|
+
voiced_s: float
|
|
28
|
+
peak: float
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Frame:
|
|
33
|
+
"""One evaluated 32 ms model frame (only with ``frame_events=True``)."""
|
|
34
|
+
|
|
35
|
+
probability: float
|
|
36
|
+
voiced: bool
|
|
37
|
+
in_speech: bool
|
|
38
|
+
silent_frames: int # consecutive unvoiced frames inside speech
|
|
39
|
+
streak: int # voiced frames toward a start
|
|
40
|
+
voiced_s: float # of the running segment
|
|
41
|
+
peak: float # of the running segment
|
|
42
|
+
infer_s: float # model inference time
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class Event:
|
|
47
|
+
kind: str # "onset" | "start" | "pause" | "end" | "frame"
|
|
48
|
+
time: float
|
|
49
|
+
segment: Optional[Segment] = None # set on "end"
|
|
50
|
+
source: str = "" # "pause": "quiet" (10 ms clock) or "silero" (first unvoiced frame)
|
|
51
|
+
frame: Optional[Frame] = None # set on "frame"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _frames(seconds: float) -> int:
|
|
55
|
+
return math.ceil(seconds / FRAME_S - EPS)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _dbfs(x: np.ndarray) -> float:
|
|
59
|
+
power = float(np.mean(x * x))
|
|
60
|
+
return max(-100.0, 10.0 * math.log10(power)) if power > 0.0 else -100.0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _pcm(audio) -> np.ndarray:
|
|
64
|
+
if isinstance(audio, (bytes, bytearray, memoryview)):
|
|
65
|
+
return np.frombuffer(audio, dtype="<i2").astype(np.float32) / 32768.0
|
|
66
|
+
a = np.asarray(audio)
|
|
67
|
+
if a.dtype == np.int16:
|
|
68
|
+
return a.astype(np.float32).reshape(-1) / 32768.0
|
|
69
|
+
return a.astype(np.float32, copy=False).reshape(-1)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class VAD:
|
|
73
|
+
"""Feed 16 kHz mono PCM; ``feed`` returns start/pause/end events in order."""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
*,
|
|
78
|
+
start_threshold: float = 0.90,
|
|
79
|
+
continue_threshold: float = 0.45,
|
|
80
|
+
start_s: float = 0.064,
|
|
81
|
+
end_s: float = 0.24, # Silero's silence count
|
|
82
|
+
quiet_end_s: Optional[float] = None, # quiet clock; default end_s
|
|
83
|
+
noisy_extra_s: float = 0.04, # added when room noise has raised the quiet threshold
|
|
84
|
+
quiet_rms: float = 0.002,
|
|
85
|
+
noise_margin_db: float = 15.0,
|
|
86
|
+
speech_margin_db: float = 15.0,
|
|
87
|
+
preroll_s: float = 0.30,
|
|
88
|
+
lookback_s: float = 1.0,
|
|
89
|
+
restore_s: float = 1.0, # Silero recovery after this much p < 0.1 over real (non-zero) audio; 0 = off
|
|
90
|
+
deaf_s: float = 0.19, # ...or after Silero ignored speech-loud audio this long
|
|
91
|
+
max_speech_s: float = 120.0,
|
|
92
|
+
extend: Optional[Callable[[float], Optional[float]]] = None,
|
|
93
|
+
frame_events: bool = False,
|
|
94
|
+
onset_events: bool = False, # "onset" at the first confident frame, before "start" confirms it
|
|
95
|
+
confirm_threshold: Optional[float] = None, # each frame of the start debounce; default continue_threshold
|
|
96
|
+
rearm_s: Optional[float] = None, # sound that re-arms the quiet clock; default start_s
|
|
97
|
+
probability: Optional[Callable[[np.ndarray], float]] = None,
|
|
98
|
+
) -> None:
|
|
99
|
+
if probability is None:
|
|
100
|
+
from .silero import Silero
|
|
101
|
+
|
|
102
|
+
probability = Silero(restore_s=0) # restores are decided here, from audio evidence
|
|
103
|
+
self._p = probability
|
|
104
|
+
self._extend = extend
|
|
105
|
+
self._frame_events = frame_events
|
|
106
|
+
self._onset_events = onset_events
|
|
107
|
+
self.start_threshold, self.continue_threshold = start_threshold, continue_threshold
|
|
108
|
+
# After the first confident frame, every frame until start_s is reached must stay at
|
|
109
|
+
# or above this (a click fades out above continue_threshold for a few frames).
|
|
110
|
+
self.confirm_threshold = continue_threshold if confirm_threshold is None else confirm_threshold
|
|
111
|
+
# The quiet clock can only make an end earlier than Silero's count, never later. Over
|
|
112
|
+
# near-silence it waits end_s like Silero; when room noise has raised the threshold, a
|
|
113
|
+
# word's fading tail already counts as quiet, so it waits noisy_extra_s longer.
|
|
114
|
+
self.end_s = end_s
|
|
115
|
+
self.quiet_end_s = end_s if quiet_end_s is None else quiet_end_s
|
|
116
|
+
self.noisy_extra_s = noisy_extra_s
|
|
117
|
+
self.quiet_rms = quiet_rms
|
|
118
|
+
self.noise_margin_db, self.speech_margin_db = noise_margin_db, speech_margin_db
|
|
119
|
+
self.start_s = start_s
|
|
120
|
+
self._end_frames = max(1, _frames(end_s))
|
|
121
|
+
self._max_frames = max(1, _frames(max_speech_s))
|
|
122
|
+
self._preroll_n = _frames(preroll_s)
|
|
123
|
+
self._lookback_n = max(0, _frames(lookback_s))
|
|
124
|
+
self._rearm_samples = max(1, math.ceil((start_s if rearm_s is None else rearm_s) * SAMPLE_RATE))
|
|
125
|
+
self._restore_n = _frames(restore_s) if restore_s > 0 else 0
|
|
126
|
+
self._deaf_n = _frames(deaf_s) if deaf_s > 0 and restore_s > 0 else 0
|
|
127
|
+
self.reset()
|
|
128
|
+
|
|
129
|
+
def reset(self) -> None:
|
|
130
|
+
reset = getattr(self._p, "reset", None)
|
|
131
|
+
if reset is not None:
|
|
132
|
+
reset()
|
|
133
|
+
self._n = 0 # samples processed
|
|
134
|
+
self._block_buf = np.zeros(0, dtype=np.float32)
|
|
135
|
+
self._frame_buf = np.zeros(0, dtype=np.float32)
|
|
136
|
+
self._echo_until = -math.inf
|
|
137
|
+
self._history: deque = deque(maxlen=self._preroll_n + self._lookback_n) # (frame, dBFS, echo)
|
|
138
|
+
self._floor: Optional[float] = None # idle floor for the onset look-back
|
|
139
|
+
self._noise_db: Optional[float] = None # room noise for the quiet threshold
|
|
140
|
+
self._speech_db: Optional[float] = None
|
|
141
|
+
self._quiet = self.quiet_rms if self.quiet_rms > 0 else -1.0 # current quiet threshold (RMS)
|
|
142
|
+
self._tail = False # waiting for Silero to drop after an early end
|
|
143
|
+
self._low = self._deaf = 0 # frames of p < 0.1 over real audio / over speech-loud audio
|
|
144
|
+
self._quiet_restored = False # the quiet rule already restored in this pause
|
|
145
|
+
self._in_speech = False
|
|
146
|
+
self._idle = 0
|
|
147
|
+
self._streak = 0
|
|
148
|
+
self._lookback_s = 0.0
|
|
149
|
+
self._clear_segment()
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def start_s(self) -> float:
|
|
153
|
+
"""Speech needed to confirm a start (the debounce after "onset"); settable while running."""
|
|
154
|
+
return self._start_s
|
|
155
|
+
|
|
156
|
+
@start_s.setter
|
|
157
|
+
def start_s(self, seconds: float) -> None:
|
|
158
|
+
self._start_s = seconds
|
|
159
|
+
self._start_frames = max(1, _frames(seconds))
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def in_speech(self) -> bool:
|
|
163
|
+
return self._in_speech
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def onset_lookback_s(self) -> float:
|
|
167
|
+
"""How far the latest start was moved back over a soft opening."""
|
|
168
|
+
return self._lookback_s
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def quiet_clock(self) -> tuple[bool, bool, int]:
|
|
172
|
+
"""(armed, unbroken, quiet blocks since it armed) of the 10 ms quiet clock."""
|
|
173
|
+
return self._t_quiet is not None, self._unbroken, self._quiet_blocks
|
|
174
|
+
|
|
175
|
+
def audio(self) -> Optional[np.ndarray]:
|
|
176
|
+
"""The running segment's audio up to the latest sample fed, or None outside speech."""
|
|
177
|
+
if not self._in_speech:
|
|
178
|
+
return None
|
|
179
|
+
return np.concatenate(self._audio + [self._frame_buf])
|
|
180
|
+
|
|
181
|
+
def segment(self) -> Optional[Segment]:
|
|
182
|
+
"""The running segment so far (end = now), or None outside speech."""
|
|
183
|
+
if not self._in_speech:
|
|
184
|
+
return None
|
|
185
|
+
return Segment(self.audio(), self._start, self._n / SAMPLE_RATE, self._last_voiced,
|
|
186
|
+
self._voiced * FRAME_S, self._peak)
|
|
187
|
+
|
|
188
|
+
def feed(self, audio, playback: bool = False) -> list[Event]:
|
|
189
|
+
"""``playback=True`` marks the chunk as possibly containing your own output (echo)."""
|
|
190
|
+
events: list[Event] = []
|
|
191
|
+
x = _pcm(audio)
|
|
192
|
+
i = 0
|
|
193
|
+
while i < len(x):
|
|
194
|
+
# advance to the next block or frame boundary, whichever is first
|
|
195
|
+
step = min(BLOCK - len(self._block_buf), FRAME - len(self._frame_buf), len(x) - i)
|
|
196
|
+
part = x[i : i + step]
|
|
197
|
+
i += step
|
|
198
|
+
self._n += step
|
|
199
|
+
self._block_buf = np.concatenate((self._block_buf, part))
|
|
200
|
+
self._frame_buf = np.concatenate((self._frame_buf, part))
|
|
201
|
+
if playback:
|
|
202
|
+
self._echo_until = self._n / SAMPLE_RATE + 0.5
|
|
203
|
+
block = self._block_buf if len(self._block_buf) == BLOCK else None
|
|
204
|
+
quiet = block is not None and self._block_start(block)
|
|
205
|
+
if len(self._frame_buf) == FRAME:
|
|
206
|
+
frame, self._frame_buf = self._frame_buf, np.zeros(0, dtype=np.float32)
|
|
207
|
+
self._frame(frame, self._n / SAMPLE_RATE, events)
|
|
208
|
+
if block is not None:
|
|
209
|
+
self._block_buf = np.zeros(0, dtype=np.float32)
|
|
210
|
+
self._block_end(quiet, self._n / SAMPLE_RATE, events)
|
|
211
|
+
return events
|
|
212
|
+
|
|
213
|
+
# -- per 10 ms block (quiet clock) -------------------------------------
|
|
214
|
+
def _block_start(self, block: np.ndarray) -> bool:
|
|
215
|
+
quiet = math.sqrt(float(np.mean(block * block))) <= self._quiet
|
|
216
|
+
if self._t_quiet is not None:
|
|
217
|
+
if quiet:
|
|
218
|
+
self._loud = 0
|
|
219
|
+
self._quiet_blocks += self._unbroken
|
|
220
|
+
else:
|
|
221
|
+
self._unbroken = False
|
|
222
|
+
self._loud += BLOCK
|
|
223
|
+
if self._loud >= self._rearm_samples:
|
|
224
|
+
self._t_quiet, self._loud, self._paused = None, 0, False
|
|
225
|
+
return quiet
|
|
226
|
+
|
|
227
|
+
def _block_end(self, quiet: bool, t: float, events: list[Event]) -> None:
|
|
228
|
+
if not self._in_speech:
|
|
229
|
+
return
|
|
230
|
+
if self._t_quiet is not None and self._unbroken and t - self._t_quiet + EPS >= self._target(t):
|
|
231
|
+
self._finish(t, events, speech_end=self._t_quiet)
|
|
232
|
+
elif quiet and self._t_quiet is None:
|
|
233
|
+
self._t_quiet, self._unbroken, self._loud, self._quiet_blocks = t, True, 0, 1
|
|
234
|
+
raised = self._quiet > self.quiet_rms * (1 + 1e-6)
|
|
235
|
+
self._quiet_wait = self.quiet_end_s + (self.noisy_extra_s if raised else 0.0)
|
|
236
|
+
self._pause(t, events, "quiet")
|
|
237
|
+
|
|
238
|
+
def _target(self, t: float) -> float:
|
|
239
|
+
"""Quiet-clock wait (set when it armed), or longer if ``extend`` asks once that is reached."""
|
|
240
|
+
wait = self._quiet_wait
|
|
241
|
+
if t - self._t_quiet + EPS < wait or self._extend is None:
|
|
242
|
+
return wait
|
|
243
|
+
extra = self._extend(t)
|
|
244
|
+
return wait if extra is None else max(wait, extra)
|
|
245
|
+
|
|
246
|
+
# -- per 32 ms Silero frame --------------------------------------------
|
|
247
|
+
def _frame(self, frame: np.ndarray, t: float, events: list[Event]) -> None:
|
|
248
|
+
t0 = time.perf_counter()
|
|
249
|
+
p = self._p(frame)
|
|
250
|
+
infer_s = time.perf_counter() - t0
|
|
251
|
+
if self._in_speech:
|
|
252
|
+
voiced = p >= self.continue_threshold
|
|
253
|
+
else: # idle: the first frame needs start_threshold, the rest of the debounce confirm_threshold
|
|
254
|
+
voiced = p >= (self.confirm_threshold if self._streak else self.start_threshold)
|
|
255
|
+
level = _dbfs(frame)
|
|
256
|
+
echo = t <= self._echo_until
|
|
257
|
+
self._history.append((frame, level, echo))
|
|
258
|
+
self._levels(p, level, echo)
|
|
259
|
+
if self._tail: # after an early end, Silero's fading tail must not open a new segment...
|
|
260
|
+
self._tail = p >= self.continue_threshold
|
|
261
|
+
if level <= 20.0 * math.log10(max(self._quiet, 1e-9)):
|
|
262
|
+
voiced = False # ...over quiet audio; speech that resumes at once is loud and may start
|
|
263
|
+
if not self._in_speech:
|
|
264
|
+
self._idle_frame(p, voiced, level, t, events)
|
|
265
|
+
self._report(t, p, voiced, infer_s, events)
|
|
266
|
+
return
|
|
267
|
+
self._audio.append(frame)
|
|
268
|
+
self._peak = max(self._peak, p)
|
|
269
|
+
if voiced:
|
|
270
|
+
if self._silent:
|
|
271
|
+
self._paused = False
|
|
272
|
+
if self._t_quiet is not None and t > self._t_quiet:
|
|
273
|
+
self._unbroken = False
|
|
274
|
+
self._voiced += 1
|
|
275
|
+
self._silent = 0
|
|
276
|
+
self._last_voiced = t
|
|
277
|
+
self._end_target = self._end_frames
|
|
278
|
+
else:
|
|
279
|
+
self._silent += 1
|
|
280
|
+
if self._silent == 1:
|
|
281
|
+
self._pause(t, events, "silero")
|
|
282
|
+
self._report(t, p, voiced, infer_s, events)
|
|
283
|
+
if len(self._audio) >= self._max_frames:
|
|
284
|
+
self._finish(t, events)
|
|
285
|
+
return
|
|
286
|
+
if self._silent >= self._end_frames and self._extend is not None:
|
|
287
|
+
extra = self._extend(t)
|
|
288
|
+
self._end_target = self._end_frames if extra is None else max(self._end_frames, _frames(extra))
|
|
289
|
+
if self._silent >= self._end_target:
|
|
290
|
+
self._finish(t, events)
|
|
291
|
+
|
|
292
|
+
def _levels(self, p: float, level: float, echo: bool) -> None:
|
|
293
|
+
"""Learn speech and room-noise levels and set the quiet threshold."""
|
|
294
|
+
if p >= self.start_threshold:
|
|
295
|
+
s = self._speech_db
|
|
296
|
+
self._speech_db = level if s is None else s + max(-1.0, min(1.0, level - s))
|
|
297
|
+
elif p < self.continue_threshold and level > -90.0 and not echo: # skip gated digital silence
|
|
298
|
+
n = self._noise_db
|
|
299
|
+
if n is None:
|
|
300
|
+
self._noise_db = level
|
|
301
|
+
elif self._in_speech: # pauses inside speech: running median, about 1 dB per frame
|
|
302
|
+
self._noise_db = n + max(-1.0, min(1.0, level - n))
|
|
303
|
+
else: # idle: falls fast, rises 3 dB/s, so a soft opening barely lifts it
|
|
304
|
+
self._noise_db = level if level <= n else n + min(level - n, 3.0 * FRAME_S)
|
|
305
|
+
if self.quiet_rms <= 0:
|
|
306
|
+
self._quiet = -1.0 # quiet clock off: no block is ever quiet
|
|
307
|
+
else:
|
|
308
|
+
# Noise can raise the threshold above quiet_rms, but only up to speech level - 15 dB
|
|
309
|
+
# (soft speech is never quiet); it never drops below quiet_rms, so it is never stricter.
|
|
310
|
+
floor_db = 20.0 * math.log10(self.quiet_rms)
|
|
311
|
+
db = floor_db
|
|
312
|
+
if self._noise_db is not None:
|
|
313
|
+
db = max(db, self._noise_db + self.noise_margin_db)
|
|
314
|
+
if self._speech_db is not None:
|
|
315
|
+
db = max(floor_db, min(db, self._speech_db - self.speech_margin_db))
|
|
316
|
+
self._quiet = 10.0 ** (db / 20.0)
|
|
317
|
+
# Silero's recurrent state drifts in long noisy or near-silent stretches until it is
|
|
318
|
+
# "deaf" (p ~ 0 through loud speech). Restore the last confident-speech state after a
|
|
319
|
+
# second of p < 0.1 over real audio (gated digital silence doesn't drift it, and a
|
|
320
|
+
# restore there only disturbs the next onset), never on a frame that sounds like an
|
|
321
|
+
# onset; or at once when Silero has ignored speech-loud audio for deaf_s.
|
|
322
|
+
#
|
|
323
|
+
# The restore point is the state after the *user's* confident speech, never after
|
|
324
|
+
# the assistant's own voice: a state that last heard playback echo scores following
|
|
325
|
+
# room noise as speech (p ~0.96 decaying over 0.4 s), which opens phantom segments.
|
|
326
|
+
restore = getattr(self._p, "restore", None)
|
|
327
|
+
snapshot = getattr(self._p, "snapshot", None)
|
|
328
|
+
if p >= self.start_threshold and not echo and snapshot is not None:
|
|
329
|
+
snapshot()
|
|
330
|
+
if self._in_speech:
|
|
331
|
+
self._quiet_restored = False # a new pause may restore once more
|
|
332
|
+
if not self._restore_n or restore is None or self._in_speech or echo:
|
|
333
|
+
self._low = self._deaf = 0
|
|
334
|
+
return
|
|
335
|
+
speech_like = self._speech_like(level)
|
|
336
|
+
# a healthy Silero passes continue_threshold within 1-3 frames of loud speech
|
|
337
|
+
self._deaf = 0 if p >= self.continue_threshold else self._deaf + speech_like
|
|
338
|
+
self._low = self._low + 1 if p < 0.1 and level > -70.0 else 0 # near-silence (gates, noise suppression) never drifts it
|
|
339
|
+
deaf = self._deaf_n and self._deaf >= self._deaf_n
|
|
340
|
+
quiet = self._low >= self._restore_n and not speech_like and not self._quiet_restored
|
|
341
|
+
if deaf or quiet: # the quiet rule fires once per pause: one restore un-sticks Silero
|
|
342
|
+
restore()
|
|
343
|
+
self._quiet_restored = self._quiet_restored or bool(quiet)
|
|
344
|
+
self._low = self._deaf = 0
|
|
345
|
+
|
|
346
|
+
def _speech_like(self, level: float) -> bool:
|
|
347
|
+
"""Loud like the speaker and clearly above the idle floor."""
|
|
348
|
+
if self._speech_db is None or self._floor is None:
|
|
349
|
+
return False
|
|
350
|
+
margin = max(3.0, min(10.0, 0.5 * (self._speech_db - self._floor)))
|
|
351
|
+
return level >= max(self._floor + margin, self._speech_db - 10.0)
|
|
352
|
+
|
|
353
|
+
def _idle_frame(self, p: float, voiced: bool, level: float, t: float, events: list[Event]) -> None:
|
|
354
|
+
floor = self._floor
|
|
355
|
+
if self._lookback_n:
|
|
356
|
+
if floor is None or level <= floor:
|
|
357
|
+
self._floor = level if floor is None else floor + (level - floor) * 0.5
|
|
358
|
+
else:
|
|
359
|
+
self._floor = floor + min(level - floor, 3.0 * FRAME_S)
|
|
360
|
+
self._idle += 1
|
|
361
|
+
self._streak = self._streak + 1 if voiced else 0
|
|
362
|
+
self._peak = max(self._peak, p) if voiced else 0.0
|
|
363
|
+
if self._streak == 1 and self._onset_events:
|
|
364
|
+
events.append(Event("onset", t)) # maybe speech; "start" follows only if it holds
|
|
365
|
+
if self._streak < self._start_frames:
|
|
366
|
+
return
|
|
367
|
+
lookback = self._lookback(floor)
|
|
368
|
+
keep = self._preroll_n if not lookback else max(self._preroll_n, min(self._preroll_n + lookback, self._idle))
|
|
369
|
+
self._in_speech = True
|
|
370
|
+
self._audio = [f for f, _, _ in list(self._history)[-keep:]]
|
|
371
|
+
self._start = t - (self._streak - 1 + lookback) * FRAME_S # end of first streak frame, as in Miep
|
|
372
|
+
self._lookback_s = lookback * FRAME_S
|
|
373
|
+
self._voiced = self._streak
|
|
374
|
+
self._last_voiced = t
|
|
375
|
+
events.append(Event("start", self._start))
|
|
376
|
+
|
|
377
|
+
def _lookback(self, floor: Optional[float]) -> int:
|
|
378
|
+
"""Frames before the start streak that still belong to a soft opening."""
|
|
379
|
+
h, s = self._history, self._streak
|
|
380
|
+
if not self._lookback_n or floor is None or len(h) < s or any(h[-i][2] for i in range(1, s + 1)):
|
|
381
|
+
return 0
|
|
382
|
+
margin = 12.0 # an opening must be clearly louder than the room...
|
|
383
|
+
if self._speech_db is not None: # ...but in loud noise, half the speech-to-noise gap is enough
|
|
384
|
+
margin = max(3.0, min(margin, 0.5 * (self._speech_db - floor)))
|
|
385
|
+
threshold = max(floor + margin, -60.0)
|
|
386
|
+
count = gap = 0
|
|
387
|
+
for step in range(1, min(self._lookback_n, self._idle - s, len(h) - s) + 1):
|
|
388
|
+
_, level, echo = h[-(s + step)]
|
|
389
|
+
if echo:
|
|
390
|
+
break
|
|
391
|
+
if level >= threshold:
|
|
392
|
+
count, gap = step, 0
|
|
393
|
+
else:
|
|
394
|
+
gap += 1
|
|
395
|
+
if gap > 3: # 0.1 s
|
|
396
|
+
break
|
|
397
|
+
return count if count > self._preroll_n - s else 0
|
|
398
|
+
|
|
399
|
+
# -- segment bookkeeping -----------------------------------------------
|
|
400
|
+
def _report(self, t: float, p: float, voiced: bool, infer_s: float, events: list[Event]) -> None:
|
|
401
|
+
if self._frame_events:
|
|
402
|
+
events.append(Event("frame", t, frame=Frame(
|
|
403
|
+
p, voiced, self._in_speech, self._silent, self._streak,
|
|
404
|
+
self._voiced * FRAME_S, self._peak, infer_s)))
|
|
405
|
+
|
|
406
|
+
def _pause(self, t: float, events: list[Event], source: str) -> None:
|
|
407
|
+
if not self._paused:
|
|
408
|
+
self._paused = True
|
|
409
|
+
events.append(Event("pause", t, source=source))
|
|
410
|
+
|
|
411
|
+
def _finish(self, t: float, events: list[Event], speech_end: Optional[float] = None) -> None:
|
|
412
|
+
segment = Segment(
|
|
413
|
+
audio=self.audio(),
|
|
414
|
+
start=self._start,
|
|
415
|
+
end=t,
|
|
416
|
+
speech_end=self._last_voiced if speech_end is None else speech_end,
|
|
417
|
+
voiced_s=self._voiced * FRAME_S,
|
|
418
|
+
peak=self._peak,
|
|
419
|
+
)
|
|
420
|
+
self._in_speech = False
|
|
421
|
+
self._tail = speech_end is not None # ended by the quiet clock, Silero may still be high
|
|
422
|
+
self._idle = self._streak = 0
|
|
423
|
+
self._clear_segment()
|
|
424
|
+
events.append(Event("end", t, segment))
|
|
425
|
+
|
|
426
|
+
def _clear_segment(self) -> None:
|
|
427
|
+
self._audio: list[np.ndarray] = []
|
|
428
|
+
self._start = self._last_voiced = self._peak = 0.0
|
|
429
|
+
self._voiced = self._silent = 0
|
|
430
|
+
self._end_target = self._end_frames
|
|
431
|
+
self._paused = False
|
|
432
|
+
self._t_quiet: Optional[float] = None
|
|
433
|
+
self._unbroken = False
|
|
434
|
+
self._loud = 0
|
|
435
|
+
self._quiet_blocks = 0
|
|
436
|
+
self._quiet_wait = self.quiet_end_s
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: realtimevad
|
|
3
|
+
Version: 0.0.4
|
|
4
|
+
Summary: Fast speech-end detection for low-latency voice assistants
|
|
5
|
+
Author: Kolja Beigel
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/KoljaB/RealtimeVAD
|
|
8
|
+
Project-URL: Issues, https://github.com/KoljaB/RealtimeVAD/issues
|
|
9
|
+
Keywords: vad,voice activity detection,endpointing,turn detection,voice assistant,realtime
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: numpy>=1.21
|
|
20
|
+
Requires-Dist: onnxruntime>=1.16
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# RealtimeVAD
|
|
26
|
+
|
|
27
|
+
*Know that the speaker stopped, about 110 ms before Silero does.*
|
|
28
|
+
|
|
29
|
+
[RealtimeSTT](https://github.com/KoljaB/RealtimeSTT) · [RealtimeTTS](https://github.com/KoljaB/RealtimeTTS) · [Live demo](https://github.com/KoljaB/RealtimeVAD/blob/main/demo/README.md) · [Spec](https://github.com/KoljaB/RealtimeVAD/blob/main/SPEC.md)
|
|
30
|
+
|
|
31
|
+
A voice assistant can't do anything until it knows the user has stopped
|
|
32
|
+
talking. Speech recognition, the LLM call and the voice output all wait for
|
|
33
|
+
that one decision, and a normal voice activity detector takes a few hundred
|
|
34
|
+
milliseconds to make it.
|
|
35
|
+
|
|
36
|
+
RealtimeVAD makes it sooner. It keeps [Silero VAD](https://github.com/snakers4/silero-vad)
|
|
37
|
+
to decide what is speech, and adds a second, faster check: every 10 ms it
|
|
38
|
+
compares the audio level with the room's noise. The moment the audio goes
|
|
39
|
+
quiet, you get an event, long before Silero's probability has fallen.
|
|
40
|
+
|
|
41
|
+
- **Input:** 16 kHz mono audio in chunks of any size (float32, int16 or int16 bytes).
|
|
42
|
+
- **Output:** `start`, `pause` and `end` events, plus the audio of the running segment at any time.
|
|
43
|
+
- **Lead:** speech-off a median 110 ms before Silero's probability drops, and 214 ms before Silero's `VADIterator` reports the end, on 3,238 real turns.
|
|
44
|
+
- **Price:** it also fires in short gaps between words, about ten times as often as Silero.
|
|
45
|
+
- **Runs on:** one CPU thread, far faster than realtime. Needs only numpy and onnxruntime; the Silero model is bundled, no torch.
|
|
46
|
+
- **Status:** alpha (0.0.4). The API may still change.
|
|
47
|
+
|
|
48
|
+
## Live demo
|
|
49
|
+
|
|
50
|
+
https://github.com/user-attachments/assets/d01900dd-9601-4f3d-8c88-db55e8bc83a1
|
|
51
|
+
|
|
52
|
+
The video uses a synthetic voice and shows a median lead of 92 ms. The 110 ms above is the median on real voice-assistant turns.
|
|
53
|
+
|
|
54
|
+
[demo/](https://github.com/KoljaB/RealtimeVAD/blob/main/demo/README.md) runs this page on your own microphone. It shows, turn by turn, how much sooner
|
|
55
|
+
RealtimeVAD knew that you stopped than Silero alone, on the same audio.
|
|
56
|
+
|
|
57
|
+
## Quick start
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install realtimevad
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from RealtimeVAD import VAD
|
|
65
|
+
|
|
66
|
+
vad = VAD()
|
|
67
|
+
for chunk in microphone_chunks():
|
|
68
|
+
for event in vad.feed(chunk):
|
|
69
|
+
if event.kind == "start":
|
|
70
|
+
stream_to_asr(vad.audio()) # includes the audio just before speech began
|
|
71
|
+
elif event.kind == "pause":
|
|
72
|
+
speculate(vad.audio()) # the speaker may be done; this isn't the end yet
|
|
73
|
+
elif event.kind == "end":
|
|
74
|
+
finalize(event.segment.audio)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Events
|
|
78
|
+
|
|
79
|
+
| Event | Meaning | When |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| `start` | Speech has begun | After about 64 ms of confident speech, so clicks don't trigger it |
|
|
82
|
+
| `pause` | The speaker may have stopped (speech-off) | Within about 10 ms of the audio going quiet |
|
|
83
|
+
| `end` | The segment is over | 0.24 s after speech-off, unless speech resumes. Never later than Silero alone |
|
|
84
|
+
| `onset` | Speech may have begun | At the first confident frame, before `start` confirms it. Off by default: `VAD(onset_events=True)` |
|
|
85
|
+
|
|
86
|
+
`vad.audio()` returns the running segment at any time, starting about 0.3 s
|
|
87
|
+
before the speech. `end` carries the finished segment with its audio, start
|
|
88
|
+
time, end time and the time the speech stopped.
|
|
89
|
+
|
|
90
|
+
If your assistant talks while the microphone is open, pass
|
|
91
|
+
`vad.feed(chunk, playback=True)` for those chunks so its own voice is handled
|
|
92
|
+
as possible echo. Every threshold and duration can be configured;
|
|
93
|
+
[SPEC.md](https://github.com/KoljaB/RealtimeVAD/blob/main/SPEC.md) lists what is guaranteed.
|
|
94
|
+
|
|
95
|
+
## How to use `pause`
|
|
96
|
+
|
|
97
|
+
`pause` is early and often wrong, on purpose. It fires on every breath, every
|
|
98
|
+
short gap between words and every stop consonant, so most `pause` events are
|
|
99
|
+
followed by more speech.
|
|
100
|
+
|
|
101
|
+
- **Use it to start work you can cancel:** finalize the transcript, call the
|
|
102
|
+
LLM, prepare the voice output. Cancel when speech continues. A wrong `pause`
|
|
103
|
+
then costs almost nothing, and a right one saves hundreds of milliseconds.
|
|
104
|
+
- **Never treat it as the end of the turn.** Only `end` closes a segment.
|
|
105
|
+
- **Can't cancel quickly?** Ignore `pause` and wait for `end`. The extra
|
|
106
|
+
false alarms would cost you more than they save.
|
|
107
|
+
|
|
108
|
+
## Already using Silero?
|
|
109
|
+
|
|
110
|
+
Change the import:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from silero_vad import load_silero_vad
|
|
114
|
+
from RealtimeVAD import VADIterator # was: from silero_vad import VADIterator
|
|
115
|
+
|
|
116
|
+
vad_iterator = VADIterator(load_silero_vad(), sampling_rate=16000)
|
|
117
|
+
speech_dict = vad_iterator(chunk, return_seconds=True) # {'start': ...} / {'end': ...} / None
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The constructor and the results stay the same, but `end` arrives as soon as the
|
|
121
|
+
audio goes quiet. Without a model argument, it uses the bundled Silero model and
|
|
122
|
+
doesn't need torch.
|
|
123
|
+
|
|
124
|
+
## Results
|
|
125
|
+
|
|
126
|
+
Measured on 3,238 turns from a live voice assistant, each with its trailing
|
|
127
|
+
silence, with default settings. Silero's decisions are computed from the very
|
|
128
|
+
same frame probabilities, so both see identical input. "Speech-off" is
|
|
129
|
+
RealtimeVAD's last `pause` before the turn ended, against the first frame of
|
|
130
|
+
Silero's final run below its threshold.
|
|
131
|
+
|
|
132
|
+
| RealtimeVAD speech-off earlier than … | median | p25 – p75 | p90 | earlier in |
|
|
133
|
+
|---|---|---|---|---|
|
|
134
|
+
| Silero, probability below 0.5 (its default threshold) | 110 ms | 84 – 190 ms | 242 ms | 98 % |
|
|
135
|
+
| Silero, probability below 0.35 (`VADIterator`'s end threshold) | 120 ms | 98 – 200 ms | 252 ms | 98 % |
|
|
136
|
+
| Silero `VADIterator` end, default 100 ms minimum silence | 214 ms | 192 – 298 ms | 356 ms | 99 % |
|
|
137
|
+
|
|
138
|
+
| Speech-off signals followed by more speech | per second of speech | turns without any |
|
|
139
|
+
|---|---|---|
|
|
140
|
+
| RealtimeVAD `pause` | 1.6 | 16 % |
|
|
141
|
+
| Silero, probability below 0.5 | 0.15 | 85 % |
|
|
142
|
+
|
|
143
|
+
Reproduce with your own recordings (16 kHz mono WAVs, one turn each, with
|
|
144
|
+
trailing silence):
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
python benchmarks/speech_off_lead.py <wav dir> out.json
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Limits
|
|
151
|
+
|
|
152
|
+
- The numbers come from one speaker and one capture chain: browser capture
|
|
153
|
+
with noise suppression and echo cancellation, mostly quiet rooms.
|
|
154
|
+
- The lead needs audio that actually goes quiet after a word, and noise
|
|
155
|
+
suppression helps that. In the 147 noisier turns (room level above −70 dBFS)
|
|
156
|
+
the median lead was 92–132 ms. Without noise suppression in a loud room,
|
|
157
|
+
expect less.
|
|
158
|
+
- Earlier is not the same as correct: whether a speech-off sometimes lands
|
|
159
|
+
inside a soft word ending was not checked against word timings.
|
|
160
|
+
- The lead is for speech-off, not for the turn end. `end` waits 0.24 s after
|
|
161
|
+
speech-off in both cases; its own gain over Silero is smaller and depends on
|
|
162
|
+
the noise.
|
|
163
|
+
- 16 kHz mono only.
|
|
164
|
+
|
|
165
|
+
## Development
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
pip install -e ".[dev]"
|
|
169
|
+
pytest
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## License and credits
|
|
173
|
+
|
|
174
|
+
MIT. The bundled speech model is [Silero VAD](https://github.com/snakers4/silero-vad) (MIT).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
RealtimeVAD/__init__.py,sha256=rumCIYSRL6ELLpdTTCPLaNjfQ03QUk4_zKYGSlcj0to,243
|
|
2
|
+
RealtimeVAD/iterator.py,sha256=DPgF8vDdQ13HiVM_-pXUiC3xyNvptBWcnpbk9mtfpQU,3062
|
|
3
|
+
RealtimeVAD/silero.py,sha256=GD_0ok6egLwvtsZOqzxnHgjyd8c-PV8Np6B-zU0VlLE,3139
|
|
4
|
+
RealtimeVAD/vad.py,sha256=qYNJV31gytEcGJNTL2vqwxal8WsE6smwHYJgT6bf4O0,20941
|
|
5
|
+
RealtimeVAD/assets/LICENSE-silero-vad.txt,sha256=Gie4kQFq7VC_m0lX9YeicyZLZmsJBLca97NaFFMGyIA,1187
|
|
6
|
+
RealtimeVAD/assets/silero_vad_v6.2.1.onnx,sha256=dnHNBLAE6QdtoNSnsaWuw2rfFhw5IwwcuUpP1dtrvSg,2845718
|
|
7
|
+
realtimevad-0.0.4.dist-info/licenses/LICENSE,sha256=DxIW6H5paKzZtBq9q3uqtZ9n_yhNa2q4wQTwxTRBXts,1090
|
|
8
|
+
realtimevad-0.0.4.dist-info/METADATA,sha256=l-6vXNka0-YoXi9vnOYZ0_o-uqfMo2OXZb9tJxBl15E,7746
|
|
9
|
+
realtimevad-0.0.4.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
10
|
+
realtimevad-0.0.4.dist-info/top_level.txt,sha256=gnTlQhGcr7VYWybQ8jfX6h08sVKfNRD_bhNmu-RquG4,12
|
|
11
|
+
realtimevad-0.0.4.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kolja Beigel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
RealtimeVAD
|