DEV Community

jidonglab
jidonglab

Posted on

Silero VAD Endpointing Cut Off 23% of Answers: 700ms Isn't a Turn

The candidate said, "The hardest part of that migration was, um..." and my AI interviewer answered, "Great, thanks for sharing that!"

They weren't done. They were thinking. My Silero VAD endpointing had decided 700 milliseconds of silence meant the turn was over, fired the LLM, and started talking over a person in the middle of a sentence. Then I pulled the logs and found out it wasn't one bad session. It was almost a quarter of all answers.

TL;DR

  • A fixed 700ms min_silence_duration_ms in Silero VAD cut off 95 of 412 answer turns (23%) in my voice app. Silence detection works fine. The flaw is treating silence as the end of a turn.
  • People thinking out loud pause for longer than 700ms all the time. In my data the 90th percentile mid-answer pause was 2.6 seconds.
  • Raising the threshold to 1500ms dropped cut-offs to 7.5% but added about 800ms of dead air to every reply. Users called it laggy.
  • A two-stage endpointer (tentative end at 600ms, commit only if the transcript looks finished, hard cap at 2.5s) got cut-offs to 2.2% with a median reply gap of 1.1s.
  • The cheapest strong signal was the last word: 61 of the 95 cut-offs ended on "so", "and", "um", or "because".

What does Silero VAD endpointing actually decide?

Silero VAD decides one thing: whether a 32ms chunk of audio contains speech. Endpointing, meaning "the user finished their turn", is a separate decision you build on top of it, usually with a silence timer. Most voice pipelines collapse those two into one parameter, and that's where it goes wrong.

Here's roughly what I shipped first:

from silero_vad import load_silero_vad, VADIterator

model = load_silero_vad()
vad = VADIterator(
    model,
    threshold=0.5,
    sampling_rate=16000,
    min_silence_duration_ms=700,
    speech_pad_ms=30,
)

for chunk in mic_chunks(512):  # 512 samples = 32ms at 16kHz
    event = vad(chunk, return_seconds=True)
    if event and "end" in event:
        end_turn()  # send transcript to the LLM, start TTS
Enter fullscreen mode Exit fullscreen mode

VADIterator emits an end event once the speech probability has stayed low for min_silence_duration_ms. It even has a bit of hysteresis built in (speech only ends once probability drops below threshold - 0.15), so it isn't twitchy. It did exactly what I told it to. I just told it the wrong thing.

700ms is a common default for chat-style voice bots because it feels snappy. "What's the weather?" "Sunny." Short turns, no thinking. My use case was the opposite.

Why did 700ms of silence cut off so many answers?

Because long, open-ended answers have long pauses inside them. When someone explains a decision they made two years ago, they stop mid-sentence to remember, to pick a word, or to decide how honest to be. Those pauses are part of the answer.

The system where this happened is Preterview, a voice mock-interview platform I run (full disclosure: I built it). It asks open-ended questions in one of three interviewer styles, listens to spoken answers, and writes a report afterward, so a single answer often runs 60 to 120 seconds with plenty of thinking in the middle. You can see what a session looks like at preterview.com/en.

Preterview — an interview session in progress

I measured it on 412 answer turns from 60 practice sessions (my own plus opted-in beta testers). I counted a turn as cut off if the speaker kept talking within 4 seconds of the endpointer firing. That's a mechanical rule, so I could run it over logs instead of relistening to every answer.

The numbers:

  • 95 of 412 turns were cut off (23%)
  • Median mid-answer pause: 1.1s
  • 90th percentile mid-answer pause: 2.6s
  • Longest pause that was clearly still the same answer: 4.3s

So a 700ms timer wasn't a little short. It sat below the median pause. The surprise to me was that it only cut off 23%, and the reason is that many people talk fast and fill gaps with "um", which Silero correctly scores as speech. The quiet thinkers got hit the hardest.

Why not just raise min_silence_duration_ms to 1500ms?

Raising the silence threshold fixes cut-offs and breaks responsiveness, because the extra wait is paid on every single turn, including the ones that were already finished. It trades a rare loud failure for a constant quiet one.

I replayed the same 412 recorded turns through the pipeline offline at 1500ms:

Config Cut-offs (of 412) Median gap before AI replies
Fixed 700ms 95 (23%) 1.0s
Fixed 1500ms 31 (7.5%) 1.8s

Cut-offs dropped by two thirds. But the median gap went from 1.0s to 1.8s, and that gap gets added on top of LLM time-to-first-token and TTS startup. Testers stopped saying "it interrupted me" and started saying "is it frozen?" One tester said "hello?" into the silence, which the VAD promptly picked up as new speech. Fun.

Also, 31 cut-offs remained. The 2.6s 90th percentile means no single fixed number works. Any threshold short enough to feel responsive is too short for the slow thinkers.

How does a two-stage endpointer fix VAD cut-offs?

A two-stage endpointer separates "the user might be done" from "the user is done". Short silence triggers a tentative end, where you start preparing a reply without speaking it. The turn only commits when a second check agrees, or when a hard maximum silence runs out.

The logic, simplified:

TENTATIVE_MS = 600
MAX_SILENCE_MS = 2500
TRAILING = {"so", "and", "but", "um", "uh", "because", "like", "which", "the"}

def looks_complete(transcript: str) -> bool:
    words = transcript.lower().rstrip(" ,").split()
    if not words:
        return False
    if words[-1].strip(".,?!") in TRAILING:
        return False
    return transcript.rstrip().endswith((".", "?", "!"))

def on_silence(ms: int, transcript: str):
    if ms >= TENTATIVE_MS and not state.speculating:
        state.speculating = True
        start_reply(transcript, speak=False)  # LLM runs, audio is held
    if ms >= TENTATIVE_MS and looks_complete(transcript):
        commit_reply()  # release held audio
    elif ms >= MAX_SILENCE_MS:
        commit_reply()

def on_speech_resumed():
    if state.speculating:
        cancel_reply()  # throw away the draft
        state.speculating = False
Enter fullscreen mode Exit fullscreen mode

Three things make this work:

  1. Speculative generation hides the wait. The LLM starts at 600ms, so by the time the commit check passes, the first tokens already exist. When a longer wait is needed, it overlaps with work that had to happen anyway.
  2. The last word carries a lot of signal. 61 of the original 95 cut-offs ended on a filler or conjunction. "So..." is almost never the end of an answer. The punctuation check relies on the streaming STT adding a period, which it does more reliably on finished sentences than on fragments.
  3. The hard cap keeps it honest. If someone trails off with "and, yeah" and goes quiet, the heuristic says incomplete, but at 2.5s the system commits anyway. Nobody waits forever.

What did the two-stage endpointer cost?

It cost wasted LLM calls. Every time a speaker resumed after a tentative end, the speculative reply was thrown away. In the replay, 18% of tentative ends got cancelled, which worked out to about 14% more LLM input tokens per session.

The final comparison on the same 412 turns:

Config Cut-offs (of 412) Median gap before AI replies
Fixed 700ms 95 (23%) 1.0s
Fixed 1500ms 31 (7.5%) 1.8s
Two-stage 600ms / 2.5s cap 9 (2.2%) 1.1s

Going from 95 interruptions to 9 for 0.1s of median latency and 14% more input tokens was an easy call. Interruptions wreck the experience. The token bill barely moved.

What are the caveats of this VAD measurement?

The biggest one: offline replay can't capture how people react. In the live 700ms sessions, interrupted speakers often stopped and let the AI talk, so some "real" cut-offs never showed up as resumed speech. My 23% is probably an undercount.

Others worth knowing:

  • The "4 seconds" rule is mine. A different window gives a different count. I picked it because no genuine new turn in my data started that quickly after an AI reply began.
  • The filler list is English-only and tuned to my speakers. A language with different discourse markers needs its own list.
  • 9 cut-offs remain. Most were people who paused for more than 2.5s after a complete-sounding sentence and then added "Oh, and also..." I've decided that's acceptable. A human interviewer would jump in there too.

So what should you set min_silence_duration_ms to?

For short command-style voice bots, 500 to 800ms is fine. For anything where people think out loud, don't make a single silence threshold your endpointer. Measure your own pause distribution first. If your median pause is anywhere near your threshold, you're interrupting people.

Silero VAD endpointing cut off 23% of my answers because a 700ms silence timer was shorter than the median thinking pause in open-ended speech, and silence alone can't tell a finished thought from a half-finished one. Raising the timer to 1500ms traded interruptions for lag. What fixed it was a two-stage endpointer: Silero VAD triggers a tentative end at 600ms, a speculative LLM reply starts quietly, and the turn commits only when the transcript looks complete (no trailing "so" or "and", ending punctuation present) or a 2.5s cap expires. That brought cut-offs from 95 to 9 out of 412 for 0.1s of extra median latency.


Written by the developer behind Preterview, an interview prep platform.

Top comments (0)