Semantic VAD — Whisper-tiny end-of-turn detector (audio only)

An audio-native end-of-turn (EOT) detector for voice agents: given the last 8 seconds of a caller's 16 kHz audio, it returns p(end of turn) — the probability that the person has finished speaking rather than paused mid-sentence. No transcript is needed, so it can answer as soon as the VAD has seen a short silence instead of waiting for an STT result.

Same recipe and input contract as pipecat smart-turn-v3 (Whisper encoder + small classification head on a fixed 8 s left-padded window), trained on real Malaysian call-centre telephony (Malay and English, both the customer and the agent channel), where turn ends are observed — the other party took the floor — rather than inferred from alignment gaps.

8 M parameters · int8 ONNX 10 MB · ≈30 ms per prediction on one CPU thread (≈65 ms for smart-turn-v3 on the same machine). A whisper-base variant with more ranking headroom is Scicom-intl/semantic-vad-eot-whisper-base.

Serving cost (int8 ONNX, measured 2026-09-08): 35 ms per prediction on one CPU thread standalone, 72 / 120 ms p50 / p90 inside a LiveKit agent process that also runs Silero and six sessions (≈ 1.2 % of a core per call at the ≈ 0.15 predictions per second a call needs); ≈ 2 000 predictions/s on a 164-core node as a dedicated process. The agent does this work itself; latency does not depend on the audio length sent (fixed 8 s window).

Results

Compared with other open detectors (eot-bench, private telephony)

Same eot-bench harness for every model (100 ms causal grid over every pause ≥ 0.1 s, threshold × action_delay × timeout policy sweep, scalar metrics scored 0.2 s into each pause), random private telephony test turns, both language tags pooled. Third-party models run through eot-bench's own adapters with the language gate widened to Malay; the text detector on transcripts from our Whisper STT (segment timestamps interpolated to words, ~30 % of these short turns have no transcript); ultraVAD without the assistant context it was designed for (this set has none); LiveKit's cloud Turn Detector v1 streamed the 300-turn set once with the data owner's approval (292 of 300 turns scored, 8 failed the gateway handshake; LiveKit Cloud caps this project at ~5 streaming turns per minute, so the run was paced).

The 300 benchmark turns (300 eot / 191 hold spans) — every detector, LiveKit cloud v1 included:

model cutoff @ 300 ms cutoff @ 600 ms latency @ 5 % cutoff latency @ 10 % cutoff AUC
Scicom Semantic VAD (enterprise model, GPU-served, private) 38.6 % 20.0 % 1 444 ms 1 034 ms 0.87
Semantic-VAD whisper-small v6 48.6 % 22.1 % 1 381 ms 1 099 ms 0.86
Semantic-VAD whisper-base v6 45.0 % 23.6 % 1 592 ms 1 260 ms 0.84
Semantic-VAD whisper-tiny v6 57.9 % 28.6 % 1 685 ms 1 332 ms 0.78
LiveKit turn-detector v1-mini (audio-only, local) 55.7 % 28.6 % 1 633 ms 1 292 ms 0.76
LiveKit Turn Detector v1 (cloud, audio) 66.7 % 27.8 % 1 568 ms 1 348 ms 0.70
ultraVAD (no text context) 65.7 % 32.1 % 1 880 ms 1 384 ms 0.65
smart-turn v3.2 73.6 % 31.4 % 1 863 ms 1 367 ms 0.64
smart-turn v2 77.1 % 32.1 % 2 000 ms 1 420 ms 0.64
LiveKit text turn-detector v0.4.1-intl (on STT transcripts) 1 952 ms 1 769 ms 0.45
VAD baseline (silence timer) 77.9 % 32.1 % 1 900 ms 1 510 ms

LiveKit's cloud v1 lands between ultraVAD and the silence timer on this Malay-heavy telephony audio (no Malay, no telephony in its training); its local v1-mini does better. Scicom Semantic VAD is the enterprise member of this family, served from a GPU with dynamic batching, and is not public.

Pareto frontier, 300 turns

Best false-cutoff rate at a 300 / 600 ms latency budget, 300 turns

Best mean latency at a 5 / 10 % false-cutoff budget, 300 turns

1 000 random test turns (1 010 eot / 569 hold spans) — the larger sample; the cloud detector was not run here:

model cutoff @ 300 ms cutoff @ 600 ms latency @ 5 % cutoff latency @ 10 % cutoff AUC
Scicom Semantic VAD (enterprise model, GPU-served, private) 43.8 % 24.5 % 1 663 ms 1 215 ms 0.87
Semantic-VAD whisper-small v6 (repo) 45.2 % 24.5 % 1 839 ms 1 226 ms 0.86
Semantic-VAD whisper-base v6 (repo) 47.3 % 25.4 % 1 812 ms 1 280 ms 0.85
Semantic-VAD whisper-tiny v6 (repo) 52.2 % 30.8 % 2 042 ms 1 503 ms 0.81
LiveKit turn-detector v1-mini (audio-only, livekit-local-inference) 63.6 % 35.7 % 2 156 ms 1 720 ms 0.74
ultraVAD (fixie-ai/ultraVAD, 0.7 B, no text context) 71.6 % 39.6 % 2 212 ms 1 784 ms 0.65
smart-turn v3.2 (pipecat-ai/smart-turn-v3) 73.7 % 36.6 % 2 296 ms 1 860 ms 0.65
LiveKit text turn-detector v0.4.1-intl (on STT transcripts) 2 381 ms 1 894 ms 0.45
smart-turn v2 (pipecat-ai/smart-turn-v2, 95 M wav2vec2) 74.1 % 39.6 % 2 500 ms 2 000 ms 0.62
VAD baseline (silence timer) 78.1 % 43.6 % 2 250 ms 1 770 ms

Pareto frontier, 1 000 turns

Best false-cutoff rate at a 300 / 600 ms latency budget, 1 000 turns

Best mean latency at a 5 / 10 % false-cutoff budget, 1 000 turns

Operating points across sets and language tags

In the pipeline and at fixed cut points

In a real LiveKit Agents 1.8 pipeline (Silero VAD → turn detector → endpointing, no STT, 300 recorded telephony turns, LiveKit defaults: VAD silence 0.55 s, min_delay 0.5 s, max_delay 3.0 s). Latency is measured from the caller's true end of speech to the pipeline's commit; a cut-off is a commit during a mid-turn pause.

turn detector latency p50 / p90 turns cut off finished turns on the fast path AUC (eot vs hold)
VAD only 0.63 / 0.71 s 14.3 %
smart-turn-v3, threshold 0.5 0.65 / 3.04 s 10.0 % 82 % 0.74
this model, threshold 0.5 0.64 / 0.74 s 10.0 % 95 % 0.84
this model, threshold 0.6 0.65 / 0.80 s 9.7 % 92 % 0.84
this model, threshold 0.6, VAD silence 0.4 s 0.49 / 0.66 s 14.0 % 92 % 0.84
whisper-base variant, threshold 0.3 0.64 / 0.74 s 9.7 % 96 % 0.88

Same interruption rate as smart-turn-v3, but 95 % of finished turns are released on the fast path, so the 3 s "not sure" tail disappears (p90 0.74 s, within 0.03 s of VAD-only). With the VAD silence lowered to 0.4 s the agent answers 0.49 s after the caller stops at the stock pipeline's interruption rate (VAD-only at 0.4 s: 21.7 % cut-offs).

Offline, at fixed cut points relative to the start of each pause (AUC, same 300 turns, every pause):

cut relative to pause start −0.4 s −0.2 s 0.0 s +0.2 s +0.6 s
smart-turn-v3 0.60 0.62 0.63 0.65 0.69
this model (int8) 0.72 0.78 0.80 0.81 0.97

The score is smooth and monotone along a pause (local std 0.04 over 200 ms, threshold flips 1.5 % per 20 ms step, vs 0.12 / 9.8 % for smart-turn-v3), which is what a pipeline that asks at VAD-tick times needs.

Under LiveKit's eot-bench harness (100 ms causal grid over every pause ≥ 0.1 s, threshold × action_delay × timeout policy sweep, operating points at fixed latency / cutoff budgets; the VAD baseline is the harness's own silence-timer policy on the same spans). All audio models scored with the same adapter, 0.2 s into each pause for the scalar metrics:

set model cutoff @ 300 ms budget cutoff @ 600 ms latency @ 5 % cutoff latency @ 10 % cutoff
telephony test, 1 000 turns, English (510 eot / 260 hold spans) this model 50.8 % 30.0 % 2 039 ms 1 529 ms
smart-turn-v3 69.6 % 35.4 % 2 269 ms 1 756 ms
VAD baseline 77.3 % 41.9 % 2 020 ms 1 610 ms
telephony test, 1 000 turns, Malay (485 / 169) this model 55.0 % 32.5 % 2 019 ms 1 423 ms
smart-turn-v3 78.1 % 39.1 % 2 635 ms 2 116 ms
VAD baseline 79.3 % 46.2 % 2 540 ms 2 060 ms
telephony, the 300 benchmark turns, English (188 / 105) this model 58.1 % 27.6 % 1 636 ms 1 198 ms
smart-turn-v3 74.3 % 30.5 % 1 649 ms 1 164 ms
VAD baseline 77.1 % 31.4 % 1 800 ms 1 510 ms
telephony, the 300 benchmark turns, Malay (112 / 35) this model 57.1 % 31.4 % 1 843 ms 1 482 ms
smart-turn-v3 68.6 % 34.3 % 2 357 ms 1 603 ms
VAD baseline 80.0 % 34.3 % 2 410 ms 1 830 ms

On telephony it beats smart-turn-v3 on every operating point and the VAD baseline on cutoff rate at both latency budgets and on latency at a 10 % cutoff budget; at a 5 % budget it ties the VAD timer. The harness asks within the first 100–300 ms of every pause, before an audio model has silence evidence (AUC 0.80 at the pause start vs 0.97 at +0.6 s), and its 5 % budget over all pauses ≥ 0.1 s forces a threshold (≈0.86) that a calibrated, non-saturating score rarely reaches — so most turns run to the timeout there. The LiveKit pipeline asks after the VAD's 0.4–0.55 s of silence, which is the operating point production runs at and where this model does its work (95 % fast path at smart-turn's interruption rate).

Files

file what
onnx/model.int8.onnx serve this — MatMul-only dynamic int8, 10 MB, ≈30 ms / CPU thread; AUC identical to fp32 within noise
onnx/model.fp32.onnx fp32 export, 32 MB, ≈56 ms; max abs Δp vs PyTorch 1e-6
onnx/export_report.json sizes, parity vs PyTorch, latency at export time
encoder/ fine-tuned Whisper-tiny encoder, HF format (config.json, model.safetensors, bf16)
eot_head.pt {"state_dict": LayerNorm→Linear(384,256)→GELU→Linear(256,1), "pooling": "last5"}
eot_window.json / preprocessor_config.json the input contract: 8 s window, 80 mel bins, 16 kHz, no mel normalisation, mean of the last 5 encoder frames
training_summary.json best step, validation AUC history

Input: input_features [batch, 80, 800] float32 — Whisper log-mel of the last 8 s of audio, left-padded with zeros when shorter, do_normalize=False. Output: probability [batch, 1], already through the sigmoid.

Usage

ONNX (recommended for serving — no torch)

import numpy as np, onnxruntime as ort
from huggingface_hub import hf_hub_download
from transformers import WhisperFeatureExtractor

REPO, SR, WINDOW = "Scicom-intl/semantic-vad-eot-whisper-tiny", 16000, 8 * 16000
opts = ort.SessionOptions(); opts.intra_op_num_threads = 1
sess = ort.InferenceSession(hf_hub_download(REPO, "onnx/model.int8.onnx"), opts, providers=["CPUExecutionProvider"])
fe = WhisperFeatureExtractor(feature_size=80, sampling_rate=SR, chunk_length=8)

def p_end_of_turn(pcm: np.ndarray) -> float:
    """pcm: float32 in [-1, 1] at 16 kHz, the caller's audio up to *now* (any length)."""
    pcm = np.asarray(pcm, dtype=np.float32)
    if pcm.size and np.abs(pcm).max() > 1.5:   # int16-scale samples (e.g. LiveKit buffers) -> unit float
        pcm = pcm / 32768.0
    pcm = pcm[-WINDOW:] if len(pcm) >= WINDOW else np.pad(pcm, (WINDOW - len(pcm), 0))
    feats = fe([pcm], sampling_rate=SR, return_tensors="np", padding="max_length", max_length=WINDOW,
               truncation=True, do_normalize=False)["input_features"].astype(np.float32)
    return float(sess.run(None, {"input_features": feats})[0].reshape(-1)[0])

Call it once the VAD has seen ≥ 0.2–0.3 s of silence; treat p ≥ 0.5 (or 0.6 for fewer interruptions) as "the turn is over". The model expects to be asked during a pause — scores taken mid-word are not meaningful.

LiveKit Agents (as the backend of STT-API's SemanticVAD)

STT-API ships SemanticVAD, an audio-native turn detector for LiveKit Agents (the streaming inference.eot interface — asked after ~200 ms of VAD silence, no transcript needed) that takes any backend with predict(pcm) -> p(eot). With p_end_of_turn from the ONNX snippet above, the whole backend is three lines (it also handles the int16-scale samples LiveKit hands over):

from stt_api.livekit_plugin.semantic_vad import SemanticVAD          # github.com/Scicom-AI-Enterprise-Organization/STT-API

class WhisperEoTBackend:
    def predict(self, pcm) -> float:          # pcm: the caller's 16 kHz audio so far
        return p_end_of_turn(pcm)

backend = WhisperEoTBackend()
session = AgentSession(
    vad=silero.VAD.load(min_silence_duration=0.4),
    turn_handling={"turn_detection": SemanticVAD(backend=backend, unlikely_threshold=0.5),
                   "endpointing": {"min_delay": 0.4, "max_delay": 1.5}},
    ...)

PyTorch (fine-tuning, research)

The stock WhisperEncoder insists on a 30 s mel; the model was trained on an 8 s window, so a 20-line subclass narrows the position table for the call. Weights are a plain WhisperEncoder state dict.

import numpy as np, torch, torch.nn as nn
from huggingface_hub import hf_hub_download
from transformers import WhisperFeatureExtractor
from transformers.models.whisper.modeling_whisper import WhisperEncoder

REPO, SR, WINDOW = "Scicom-intl/semantic-vad-eot-whisper-tiny", 16000, 8 * 16000

class VariableLengthWhisperEncoder(WhisperEncoder):
    def forward(self, input_features, attention_mask=None, **kw):
        n = input_features.shape[-1] // 2
        prev = (self.config.max_source_positions, self.embed_positions.num_embeddings)
        self.config.max_source_positions = self.embed_positions.num_embeddings = n
        try:
            return super().forward(input_features, attention_mask, **kw)
        finally:
            self.config.max_source_positions, self.embed_positions.num_embeddings = prev

class EoTHead(nn.Module):
    def __init__(self, d, hidden=256):
        super().__init__()
        self.norm, self.fc1, self.act, self.fc2 = nn.LayerNorm(d), nn.Linear(d, hidden), nn.GELU(), nn.Linear(hidden, 1)
    def forward(self, x):
        return self.fc2(self.act(self.fc1(self.norm(x)))).squeeze(-1)

encoder = VariableLengthWhisperEncoder.from_pretrained(REPO, subfolder="encoder").float().eval()
payload = torch.load(hf_hub_download(REPO, "eot_head.pt"), map_location="cpu")
head = EoTHead(encoder.config.d_model); head.load_state_dict(payload["state_dict"]); head.eval()
fe = WhisperFeatureExtractor(feature_size=80, sampling_rate=SR, chunk_length=8)

@torch.inference_mode()
def p_end_of_turn(pcm):
    pcm = np.asarray(pcm, dtype=np.float32)
    pcm = pcm[-WINDOW:] if len(pcm) >= WINDOW else np.pad(pcm, (WINDOW - len(pcm), 0))
    feats = fe([pcm], sampling_rate=SR, return_tensors="pt", padding="max_length", max_length=WINDOW,
               truncation=True, do_normalize=False)["input_features"]
    hidden = encoder(feats).last_hidden_state        # [1, 400, 384]
    pooled = hidden[:, -5:, :].mean(1)               # "last5": mean of the last 100 ms
    return torch.sigmoid(head(pooled)).item()

Training

  • Data: a private Malaysian call-centre telephony dataset (stereo recordings split into the customer's and the agent's own channel, one row per turn with the observed end of turn and the mid-turn pauses as hold spans). All train + validation files of both speakers; a fixed random 4 000-cut sample of the test split for validation and early stopping (patience 3). The 300-turn pipeline benchmark is drawn from that same test split — model selection touched benchmark data, so read the numbers above as in-distribution.
  • Examples: each silence span is cut at 6 offsets drawn uniformly in [−0.4, +1.2] s around the pause start (clipped to the span end); the final pause is labelled 1, earlier pauses 0. The audio after the cut is dropped; the model sees the last 8 s before the cut. Do not train this with a fixed grid of offsets: the amount of trailing silence is trivially readable from the mel, the grid gets memorised (p ≈ 1 exactly at the trained silence durations, 0.05–0.5 in between), and a LiveKit pipeline — which asks ~0.3 s into a pause — lands between the grid points. Five earlier versions of this model failed that way while scoring 0.98 AUC offline.
  • Model: openai/whisper-tiny encoder (4 layers, d 384), fully fine-tuned, bf16, + EoTHead on the mean of the last 5 encoder frames. Batch 128, AdamW lr 5e-5, constant after warm-up. Early-stopped at step 8 000 (validation AUC 0.859 on uniformly sampled cuts).
  • Export: torch.onnx.export at the fixed 800-frame input, then onnxruntime dynamic quantization of MatMul only (quantizing the convolutions as well was slower and less accurate).

Limitations

  • Trained on Malaysian call-centre telephony (narrowband, Malay/English code-switching, customer and agent voices). Untested on other domains and languages; on Malay read speech (ms_dialects of Scicom-intl/semantic-vad-eot) it matches the VAD baseline, it does not beat it.
  • Noise cancellation in front of it (GTCRN) lowered the fast-path share in our pipeline: the model expects the raw phone channel. Measure before adding any pre-processing.
  • Feed unit-scale float audio. int16-scale samples shift the log-mel by ~90 dB and turn the output into a coin flip — the snippet above rescales, so should any other integration.
  • A hold decision within the first 200 ms of a pause is genuinely hard from audio alone (AUC 0.80 at the pause start vs 0.97 at +0.6 s); the model is meant to be queried after a short VAD silence, not on every frame.
  • Only a 300-turn pipeline sample and a 1 000-turn offline sample stand behind the numbers; differences of one or two cut-off turns are noise.

License

Apache-2.0 (the Whisper encoder it fine-tunes is Apache-2.0).

Downloads last month

-

Downloads are not tracked for this model. How to track
Inference Providers NEW
This model isn't deployed by any Inference Provider. 🙋 Ask for provider support

Model tree for Scicom-intl/semantic-vad-eot-whisper-tiny

Quantized
(243)
this model

Collection including Scicom-intl/semantic-vad-eot-whisper-tiny