OpenAI Realtime API Turn Detection & Interruption Errors
Turn detection controls when the model thinks the user is done talking. Get it wrong and you either cut users off mid-sentence or make them wait 5 seconds before the model responds. Interruption (barge-in) has its own settings that trip teams up. Here's the tuning guide and the specific bugs each mode produces.
By Ahmed R. · Last updated Aug 14, 2026 · OpenAI · Page #147
server_vad (voice activity detection — fast, threshold-based), semantic_vad (model decides when you're done — slower but smarter for hesitant speakers), and null (manual, you commit the buffer). Set interrupt_response=true to allow barge-in. Tune silence_duration_ms for your users — 200ms is snappy but cuts thinkers off; 500ms is safer for hesitant speech.Real error messages you'll see
# User pauses to think mid-sentence; server_vad fires speech_stopped at 200ms;
# model responds before user finishes.
# Fix: raise silence_duration_ms to 400-600ms, or switch to semantic_vad.
# User says "yes." — too short to trigger VAD threshold.
# Or ambient noise keeps VAD in "speech" state forever.
# Fix: lower VAD threshold; add prefix_padding_ms; check mic gain.
# User starts talking while model is speaking; model continues.
# Root cause: interrupt_response=false (default in some versions) or client didn't
# stop feeding audio during model playback.
# Fix: interrupt_response=true; client sends input_audio_buffer.clear on speech_started during response.
Turn detection modes
| Mode | How it works | Best for |
|---|---|---|
server_vad | Threshold-based: silence for N ms = user done | Fast conversations, clear speech |
semantic_vad | Model decides when user is done based on content | Hesitant speakers, dictation, natural pauses |
null (manual) | You send input_audio_buffer.commit | Push-to-talk, controlled environments |
| Interruption | interrupt_response=true + client stops feeding audio on speech_started | Barge-in support |
Root causes (ranked by frequency)
Based on OpenAI developer reports; percentages sum to 100%.
- 22%
silence_duration_mstoo short. Default of 200ms cuts thinkers off mid-sentence. Bump to 400-600ms for natural conversation. - 18%VAD threshold wrong for input.
threshold: 0.5is a starting point. Noisy environments need higher (0.7+); quiet mics need lower (0.3). - 14%
interrupt_response=false. Model keeps talking over the user. Set to true and handle the client-side audio-clear on speech_started. - 12%Client keeps sending audio during model playback. Even with interrupt_response=true, if the client doesn't stop feeding the user's audio to the model, model won't detect the interruption.
- 10%Manual mode without
commit. Set turn_detection=null but forgot to sendinput_audio_buffer.commit. Server waits forever. - 9%semantic_vad added latency ignored. Adds ~500-1000ms per turn vs server_vad. Not always the right default despite being "smarter".
- 8%
prefix_padding_mstoo low. Beginning of utterances gets clipped. Bump to 300-500ms so the first few frames aren't dropped as ambient noise. - 7%Duplicate response after manual commit. Sent
input_audio_buffer.committhenresponse.create, but auto-response was on — model responds twice.
How to fix it
Tune server_vad for your users — silence duration, threshold, padding
The primary fix for cut-off and delayed responses.
Server VAD has three knobs: threshold (VAD sensitivity, 0-1), prefix_padding_ms (audio kept before speech detected, so first syllables aren't clipped), and silence_duration_ms (how long silence must persist before speech is considered ended). Defaults work for demo conditions; production needs tuning per audience.
import json
# ✅ CONVERSATIONAL — snappy responses, clear speakers
CONVERSATIONAL_VAD = {
"type": "server_vad",
"threshold": 0.5, # standard sensitivity
"prefix_padding_ms": 300, # 300ms of audio before speech kept (preserves first syllable)
"silence_duration_ms": 200, # 200ms silence = user done (snappy)
"create_response": True,
"interrupt_response": True,
}
# ✅ HESITANT / THOUGHTFUL SPEAKERS — allow pauses
HESITANT_VAD = {
"type": "server_vad",
"threshold": 0.5,
"prefix_padding_ms": 400,
"silence_duration_ms": 600, # 600ms — lets users pause and continue
"create_response": True,
"interrupt_response": True,
}
# ✅ NOISY ENVIRONMENT — higher threshold to reject background noise
NOISY_VAD = {
"type": "server_vad",
"threshold": 0.7, # only clear speech triggers
"prefix_padding_ms": 300,
"silence_duration_ms": 400,
"create_response": True,
"interrupt_response": True,
}
# ✅ TELEPHONY — narrower band, lower fidelity
TELEPHONY_VAD = {
"type": "server_vad",
"threshold": 0.5,
"prefix_padding_ms": 200, # phone lines add their own latency
"silence_duration_ms": 500, # phone users tend to pause more
"create_response": True,
"interrupt_response": True,
}
# ✅ Semantic VAD — model decides, slower but handles natural speech
SEMANTIC_VAD = {
"type": "semantic_vad",
"eagerness": "medium", # low = wait longer, high = interrupt sooner
"create_response": True,
"interrupt_response": True,
}
# ✅ Manual (push-to-talk) — client controls turn boundaries
MANUAL_TURN = None # turn_detection: null
# ✅ Apply the right mode per session context
def session_for_user(user_profile: dict) -> dict:
if user_profile.get("prefers_slow_pace"):
return HESITANT_VAD
if user_profile.get("channel") == "telephony":
return TELEPHONY_VAD
if user_profile.get("environment") == "noisy":
return NOISY_VAD
return CONVERSATIONAL_VAD
async def configure_session(ws, user_profile: dict):
await ws.send(json.dumps({
"type": "session.update",
"session": {
"audio": {"input": {"format": "pcm16"}, "output": {"format": "pcm16"}},
"turn_detection": session_for_user(user_profile),
},
}))
# ✅ Adaptive tuning — watch for user complaints or cut-offs, adjust
class AdaptiveVAD:
def __init__(self):
self.silence_ms = 300
self.cutoff_events = 0 # times user restarted after being cut off
def on_user_restart_within_2s(self):
"""User immediately spoke again after model responded — likely cut off."""
self.cutoff_events += 1
if self.cutoff_events >= 3:
self.silence_ms = min(self.silence_ms + 100, 800)
self.cutoff_events = 0
def current_config(self) -> dict:
return {
"type": "server_vad",
"threshold": 0.5,
"prefix_padding_ms": 300,
"silence_duration_ms": self.silence_ms,
"create_response": True,
"interrupt_response": True,
}
silence_duration_ms=300-400, not the demo default of 200. Adjust based on user complaints. Sales / support use cases with patient users tolerate 500-600ms; kiosks and IVR benefit from 200-300ms.Implement barge-in — interrupt_response=true + client audio-clear
Fixes model talking over the user.
Barge-in requires two things: server-side interrupt_response=true AND client behavior that stops feeding user audio to the model during model playback. When the model is speaking and the user starts talking, the server emits input_audio_buffer.speech_started; the client should immediately clear any queued audio output to the user's speakers.
import json
import base64
class VoicePlaybackController:
"""Manages the user's speaker output; supports mid-playback cancellation."""
def __init__(self, audio_sink):
self.sink = audio_sink # your platform-specific audio output
self.playing = False
self.pending_chunks: list[bytes] = []
async def enqueue(self, audio_bytes: bytes):
self.pending_chunks.append(audio_bytes)
if not self.playing:
self.playing = True
await self._play_loop()
async def _play_loop(self):
while self.pending_chunks and self.playing:
chunk = self.pending_chunks.pop(0)
await self.sink.write(chunk)
self.playing = False
async def cancel(self):
"""Stop playing immediately — user is interrupting."""
self.playing = False
self.pending_chunks.clear()
await self.sink.flush_and_stop()
# ✅ Full barge-in loop
async def voice_loop(ws, playback: VoicePlaybackController):
model_is_speaking = False
current_response_id = None
async for msg in ws:
event = json.loads(msg)
t = event["type"]
if t == "response.created":
model_is_speaking = True
current_response_id = event["response"]["id"]
elif t == "response.output_audio.delta":
audio = base64.b64decode(event["delta"])
await playback.enqueue(audio)
elif t == "response.done":
model_is_speaking = False
elif t == "input_audio_buffer.speech_started":
# USER IS TALKING — implement barge-in
if model_is_speaking:
# 1. Stop playing model audio to user
await playback.cancel()
# 2. Tell server how much audio actually reached the user
# (server needs this to reconstruct what the user heard vs. what was cancelled)
await ws.send(json.dumps({
"type": "response.cancel",
}))
model_is_speaking = False
elif t == "input_audio_buffer.speech_stopped":
# User done — server_vad auto-triggers response.create if create_response=true
pass
elif t == "conversation.item.input_audio_transcription.completed":
# Whisper transcribed what the user said — good for logging
print(f"[user said] {event['transcript']}")
# ✅ WebRTC handles barge-in largely automatically
# The browser's peer connection stops playing model audio when the local mic
# picks up user speech, IF you've wired the local stream to the peer connection.
# Just make sure interrupt_response=true in the session config.
# ✅ Handle response.cancelled cleanly
async def on_response_done(event, playback):
if event.get("response", {}).get("status") == "cancelled":
# User interrupted; ensure no leftover chunks in playback buffer
await playback.cancel()
# Optionally send conversation.item.truncate so the server knows
# exactly how much audio the user heard
item_id = event["response"]["output"][-1]["id"] if event["response"]["output"] else None
if item_id:
await ws.send(json.dumps({
"type": "conversation.item.truncate",
"item_id": item_id,
"content_index": 0,
"audio_end_ms": playback.total_played_ms,
}))
conversation.item.truncate after a cancellation, the server thinks the user heard the whole model response even though playback was stopped. Next turn, the model may reference something the user never heard. Sending truncate keeps the model's mental model of the conversation aligned with reality.Semantic VAD when threshold-based fails — natural pauses, dictation
When to switch modes.
semantic_vad uses the model itself to decide when a user is done speaking based on content — a trailing "um..." or a question that hasn't been asked yet keeps the turn open. Trade-off: adds ~500-1000ms latency per turn vs threshold-based. Use it for hesitant users, dictation flows, or any case where natural pauses would trip server_vad.
# ✅ Semantic VAD — model decides based on content
SEMANTIC_VAD_SESSION = {
"type": "session.update",
"session": {
"audio": {"input": {"format": "pcm16"}, "output": {"format": "pcm16"}},
"turn_detection": {
"type": "semantic_vad",
"eagerness": "medium", # "low" | "medium" | "high" | "auto"
"create_response": True,
"interrupt_response": True,
},
},
}
# eagerness values:
# "low" — waits longer; best for slow speakers, minimizes false cuts
# "medium" — balanced; good default for most conversational apps
# "high" — cuts sooner; feels snappy but may interrupt thinkers
# "auto" — model picks based on session context
# ✅ Manual turn detection — push-to-talk, controlled environments
MANUAL_SESSION = {
"type": "session.update",
"session": {
"audio": {"input": {"format": "pcm16"}, "output": {"format": "pcm16"}},
"turn_detection": None, # null = manual
},
}
async def push_to_talk(ws, mic_stream):
"""User holds a button; we send audio; on release we commit."""
await ws.send(json.dumps(MANUAL_SESSION))
async for pcm_chunk in mic_stream:
await ws.send(json.dumps({
"type": "input_audio_buffer.append",
"audio": base64.b64encode(pcm_chunk).decode("ascii"),
}))
# User released the button
await ws.send(json.dumps({"type": "input_audio_buffer.commit"}))
await ws.send(json.dumps({"type": "response.create"}))
# ✅ Hybrid — start with semantic_vad, downgrade if latency complaints mount
class TurnDetectionAdapter:
def __init__(self):
self.mode = "semantic_vad"
self.latency_samples: list[float] = []
def record_turn_latency(self, seconds: float):
self.latency_samples.append(seconds)
# Keep last 20 samples
self.latency_samples = self.latency_samples[-20:]
def should_downgrade(self) -> bool:
"""If p50 latency > 1.5s, drop to server_vad."""
if len(self.latency_samples) < 5:
return False
sorted_lat = sorted(self.latency_samples)
p50 = sorted_lat[len(sorted_lat) // 2]
return p50 > 1.5
async def maybe_switch(self, ws):
if self.mode == "semantic_vad" and self.should_downgrade():
self.mode = "server_vad"
await ws.send(json.dumps({
"type": "session.update",
"session": {
"turn_detection": {
"type": "server_vad",
"silence_duration_ms": 300,
"create_response": True,
"interrupt_response": True,
},
},
}))
# ✅ Common misconception: semantic_vad + interrupt_response
# Semantic VAD DOES support interrupt_response=true. The model can decide
# both "user is done speaking" AND "user is interrupting me" from content.
# It's not either/or.
server_vad with tuned silence_duration_ms usually wins.Prevention checklist
- Start with
silence_duration_ms=300-400, not the demo default 200. Adjust based on your users. - Set
interrupt_response=trueAND handle the client-side audio-clear onspeech_started. Both are needed for barge-in. - After a cancelled response, send
conversation.item.truncateso the server knows what the user actually heard. - Use
semantic_vadfor hesitant speakers, dictation, accessibility — at ~1s added latency. - For push-to-talk,
turn_detection=nulland manually sendinput_audio_buffer.committhenresponse.create. - Tune
thresholdfor the environment — noisy: 0.7+; quiet mic: 0.3. - Bump
prefix_padding_msto 300-500 so the first syllable of each utterance isn't clipped.
Frequently asked questions
Server VAD adds only silence_duration_ms (typically 200-500ms) between the user's last word and the response starting. Semantic VAD adds that plus a model round-trip to decide "is the user done?" — usually 500-1000ms more. For conversational apps, server VAD feels snappier; for dictation and thoughtful conversations, semantic VAD feels more natural despite being slower.
Your silence_duration_ms is shorter than your natural pauses. If you pause 350ms to think and silence_duration_ms is 200ms, the model thinks you're done. Raise silence_duration_ms to 500-600ms, or switch to semantic_vad which recognizes an incomplete thought.
Yes with interrupt_response=true and G.711 audio, but telephony has its own echo suppression that may swallow the user's barge-in speech. Verify that user audio actually reaches your Realtime WebSocket bridge (Twilio Media Streams pass it through). If interrupts still don't register, lower the VAD threshold on the user side.
It controls how eagerly the model ends a turn. "low" waits longer before deciding you're done (best for slow speakers); "high" ends turns snappier; "medium" and "auto" balance. Higher eagerness reduces latency but risks cutting slow speakers off. Start with "medium" and adjust based on user feedback.
Combine manual turn detection (turn_detection: null) with client-side keyword detection. Your client transcribes locally (e.g. with a small Whisper model or a browser API) and only sends input_audio_buffer.commit when it detects the trigger phrase. This is the "Hey Assistant" pattern — wake word local, conversation via Realtime.