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.
@@ -0,0 +1,8 @@
1
+ """RealtimeVAD: fast speech-end detection for low-latency voice assistants."""
2
+
3
+ from .iterator import VADIterator
4
+ from .vad import VAD, Event, Segment
5
+
6
+ __version__ = "0.0.4"
7
+
8
+ __all__ = ["VAD", "VADIterator", "Event", "Segment", "__version__"]
@@ -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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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