OpenAI Realtime API interruptions and barge-in: truncate with the audio the user actually heard
Over WebSocket the client owns playback. Keep a playback ledger per item and send conversation.item.truncate with the milliseconds the user actually heard.
A Realtime API session over WebSocket generates speech faster than it plays. When the user talks over it, the server's conversation already holds the whole reply, while the speaker may have played two seconds of it. If your client stops the audio and does nothing else, the model keeps the whole reply in context, and the next turn is built on it. OpenAI's fix is one client event, conversation.item.truncate, and the number it needs, audio_end_ms, is something only the client has: how many milliseconds of that item reached the speaker.
Most of the interruption bugs I read in public Realtime clients come from how that number is produced. This post shows the bookkeeping as a ledger of under 50 lines of JavaScript, along with the six public bugs it is built around. Over WebRTC and SIP the server truncates unplayed audio itself, according to OpenAI's guide, so if you are free to choose that transport, my view is that you should skip all of this.
Over WebSocket the server does not know what was played
OpenAI's reference for conversation.item.truncate says the server "will produce audio faster than realtime, so this event is useful when the user interrupts to truncate audio that has already been sent to the client but not yet played." The interruption guide splits the work by transport. With WebRTC and SIP the server "will automatically truncate unplayed audio when there's a user interruption". With WebSocket, "the client manages audio playback, and thus must stop playback and handle truncation." OpenAI's support account gave the same answer on 24 October 2026 to a developer whose agent was being cut off by coughs and door noise.
The shape of the event constrains the code. audio_end_ms is an inclusive duration, and if it is greater than the real duration of the audio, "the server will respond with an error". Only assistant message items can be truncated, and content_index is 0. The reference also says truncating deletes the server-side transcript so that no unheard text stays in context, and the guide adds that you do not get a truncated transcript back, because the model cannot align text to audio precisely.
Success comes back as conversation.item.truncated. A failure comes back as an error event. OpenAI's reference gives it the event_id of the client event that caused it, "if applicable", so give every truncate an event_id, and also keep the truncates that have not been answered yet: an error without a usable event_id can then be matched to the one sent last.
The events around an interruption and what the client does with each
| Event | Sent by | What it means | What the client does |
|---|---|---|---|
input_audio_buffer.speech_started | Server | Speech detected in the input buffer in server_vad mode; the reference says the client may use it to interrupt playback | Flush the player. The item_id in this event is the user's item, not an assistant item. |
response.cancel | Client | Cancels an in-progress response; the server answers with response.done and status cancelled | Send it when VAD does not cancel for you. Pass response_id to name the response; without it the server cancels the one in progress. |
response.done with status cancelled | Server | Per the reference, status_details.reason is turn_detected when VAD cancelled it and client_cancelled when you did | Route on this event. The guide mentions a response.cancelled event that the reference does not define. |
response.output_audio.done | Server | No more audio chunks for the item; "also emitted when a Response is interrupted, incomplete, or cancelled" | Mark the item done. It says nothing about what the user heard. |
Whether VAD cancels the response for you depends on interrupt_response. The reference says that when it is true the response is cancelled on a VAD start event, "otherwise it will continue until complete", and it gives no default. Set it explicitly. If you set it to false, send response.cancel yourself. OpenAI's docs do not say how a cancelled response is billed, so I would not assume that audio generated but never played is free.
A playback ledger gives every item its own offset
For each assistant item the client tracks how much audio arrived, how much was played, and whether the server finished sending it. Each comes from a different place. Samples received come from response.output_audio.delta: decode the base64 and divide the byte length by 2, since the default format, audio/pcm, is mono signed 16-bit PCM and the reference says "only a 24kHz sample rate is supported" for it. That makes 24 samples per millisecond. If you configure G.711 for telephony (audio/pcmu or audio/pcma), a sample is one byte and the rate is 8 per millisecond, so the constant changes.
Samples played must come from the player, not from a timer. In a browser, an AudioWorkletProcessor has its process() method called for each block of audio, 128 frames at the time of the MDN page, so the worklet can count the frames it takes from each item's queue and report them. Convert them to the 24 kHz units of the stream before they reach the ledger if the audio context runs at another rate. A count taken in the worklet runs ahead of the speaker by AudioContext.outputLatency, which MDN describes as varying "depending on the platform and the available hardware". The done flag comes from response.output_audio.done.
This is the ledger. received refuses chunks of an item that was already truncated, played can never pass received, and interrupt() returns one truncate event for each item that has unheard audio.
const SAMPLES_PER_MS = 24;
export class PlaybackLedger {
#items = new Map();
#sequence = 0;
received(itemId, samples) {
const item = this.#items.get(itemId) ?? { received: 0, played: 0, done: false, truncated: false };
this.#items.set(itemId, item);
if (item.truncated) return false;
item.received += samples;
return true;
}
done(itemId) {
const item = this.#items.get(itemId);
if (item) item.done = true;
}
played(itemId, samples) {
const item = this.#items.get(itemId);
if (item && !item.truncated) item.played = Math.min(item.received, item.played + samples);
}
interrupt() {
const events = [];
for (const [itemId, item] of this.#items) {
const heardInFull = item.done && item.received - item.played < SAMPLES_PER_MS;
if (item.truncated || heardInFull) continue;
item.truncated = true;
events.push({
type: "conversation.item.truncate",
event_id: `tr-${++this.#sequence}`,
item_id: itemId,
content_index: 0,
audio_end_ms: Math.floor(item.played / SAMPLES_PER_MS),
});
}
return events;
}
reset() {
this.#items.clear();
}
}
The wiring routes the server events into it. The player is whatever you already have, as long as enqueue(itemId, base64) queues audio and flush() stops it and calls ledger.played with the last frames it consumed before it returns.
import { PlaybackLedger } from "./playback-ledger.mjs";
export const ledger = new PlaybackLedger();
const pending = new Map();
export function handleServerEvent(event, { player, send }) {
switch (event.type) {
case "response.output_audio.delta": {
const samples = atob(event.delta).length / 2;
if (ledger.received(event.item_id, samples)) player.enqueue(event.item_id, event.delta);
break;
}
case "response.output_audio.done":
ledger.done(event.item_id);
break;
case "input_audio_buffer.speech_started":
interrupt({ player, send });
break;
case "conversation.item.truncated":
for (const [id, sent] of pending) if (sent.item_id === event.item_id) pending.delete(id);
break;
case "error": {
const sent = pending.get(event.error?.event_id) ??
(event.error?.code === "invalid_value" ? [...pending.values()].at(-1) : undefined);
if (sent) {
pending.delete(sent.event_id);
console.error("truncate rejected", sent, event.error);
}
break;
}
}
}
export function interrupt({ player, send, cancel = false }) {
player.flush();
if (cancel) send({ type: "response.cancel" });
for (const truncate of ledger.interrupt()) {
pending.set(truncate.event_id, truncate);
send(truncate);
}
}
The wiring assumes 16-bit PCM, so for G.711 drop the division by 2 and change the constant. Call interrupt({ player, send, cancel: true }) from a stop button, or from your own barge-in logic when interrupt_response is false; for the filter described below, replace the speech_started case with that logic. The flush comes first on purpose, because the frames the player consumes while stopping are part of the number. A truncate stays in pending until conversation.item.truncated arrives for its item or an error is matched to it, by its event_id when the error carries one and otherwise by the invalid_value code against the truncate sent last. Call ledger.reset() and clear pending whenever you open a new connection.
Six public truncate bugs show where the offset goes wrong
For each issue below I wrote a scenario from its description, 14 for the ledger and 8 for its wiring, to check that the ledger avoids the failure. The clients are in Python and TypeScript and the scenarios use synthetic events, so they test the ledger's logic, not those clients.
| Issue | What the client did | Status | Ledger guard |
|---|---|---|---|
| openai-agents-python #2370 | Sent truncate events with no interruption; audio_end_ms exceeded the audio length, the API returned an error, and the reporter saw the model start repeating itself | Fixed in #2385, merged 2026-02-03 | Nothing is sent unless an interruption happens, and the offset never passes the samples received |
| openai-agents-python #5190 | A barge-in during a later response with no audio, such as a tool call, truncated the previous item at full length although the user had heard all of it | Fixed in #5193, merged 2026-09-27 | An item that is done and played in full is skipped |
| openai-agents-python #4456 | An interrupt in the first Windows clock tick measured 0 ms elapsed and skipped the truncate | Fixed in #4457, merged 2026-08-16 | Zero is a valid offset, so an interruption before any audio played still truncates at 0 |
| vercel/ai #22855 | With two items queued, the playing item's offset was applied to the newest item, and the playing item was never truncated | Issue closed 2026-10-13; the fix #22883 was closed unmerged. On 2026-10-27 realtime-session.ts on main still held a single currentResponseItemId | Counters per item, and one truncate for every item with unheard audio |
| openclaw #138592 | On Twilio g711_ulaw calls the offset came from the call clock, not from audio played, and the API rejected it ("Audio content of 4050ms is already shorter than 80920ms") | Fixed in #138619, merged 2026-09-10 | The offset is samples played for that item, never elapsed time |
| openai-agents-python #5068 | A reconnect on the same model object kept the old item ids, so the first speech_started truncated an item the new session had never seen | Fixed in #5069, merged 2026-09-25 | reset() on every new connection |
Two of these report a symptom in the model itself: the repeating in #2370 and, in #138592, an agent that went silent after its truncate was rejected. The ledger also refuses audio chunks that arrive for an item after its truncate, as a precaution against late deltas.
Filtering false barge-ins takes its own logic in front of the truncate
A correct truncate keeps the server's context honest after a false barge-in, and the barge-in still happens. Three forum threads report coughs, doors, and short acknowledgements such as "hmm" and "okay" stopping the agent mid-sentence: one opened on 20 October 2026 about calls in Portuguese on gpt-realtime-mini, and two older ones about filler words and sensitivity at a high threshold. OpenAI's support account answered the October thread with audio.input.noise_reduction, with near_field for close-talking microphones and far_field for laptop or conference-room microphones, and added that truncating "doesn't guarantee seamless continuation after a false interruption." In the two older threads it said that semantic VAD "isn't a guaranteed filter for 'hmm' or 'okay.'"
The VAD guide and reference document the server_vad fields: threshold defaults to 0.5 and a higher value needs louder audio, prefix_padding_ms to 300 and silence_duration_ms to 500. The posters in both filler-word threads said that tuning those three did not fix it. One developer there reports replacing server VAD with a separate end-of-turn model, Deepgram Flux with eot_threshold around 0.5.
My view is that the place for a minimum-speech filter is your own barge-in logic with interrupt_response set to false. The reference says the response then "will continue until complete", so you receive speech_started and decide when to call interrupt({ cancel: true }), for example after a fixed amount of speech. The ledger makes a late decision safe, because the played count is correct at whatever moment you call it. The same reference page warns that with interrupt_response off, create_response "may fail to create a response if the model is already responding", so test the turn that follows.
After a false interruption the unheard tail is gone from the server's context. If you want to offer it again, keep the text from response.output_audio_transcript.delta yourself and repeat the whole last sentence. Cutting the text at a millisecond is guesswork, since the guide says the model cannot align text to audio.
The AIVAX Voice Session documents the same sequence
AIVAX's Voice Session speaks the GA Realtime events over WebSocket. Its section on interruptions lists the sequence in this post: stop or duck playback on the confirmed input_audio_buffer.speech_started, send response.cancel if a response is still being generated, then send the conversation-item truncation with the assistant item id and the amount of audio that was actually played. It adds two rules that the ledger follows. Track played duration in the client "rather than assuming every received chunk reached the speaker", and treat already-finished response races as normal and make cancel handling idempotent. The audio format there is raw mono signed 16-bit little-endian PCM at 24,000 Hz, the unit behind the ledger's 24 samples per millisecond. The Voice product page puts the split the same way: your client stops the old playback and handles cancellation when the person interrupts.
The session takes @openai/gpt-realtime-2.1 and @openai/gpt-realtime-2.1-mini; the unprefixed spellings of those two names still work until 1 December 2026, per the changelog of 1 November. Name gpt-realtime-2.1-mini or gpt-realtime-2.1 rather than the earlier gpt-realtime and gpt-realtime-mini, which OpenAI retires on 20 January 2027. The October shutdown post and the GPT-4o migration post list the audio dates.
Read on 2026-10-27: OpenAI's Realtime references and guides, its deprecations page, the forum threads, issues and pull requests linked above, MDN, and AIVAX's Voice Session documentation and Voice page. I ran the ledger (14 scenarios) and its wiring (8) in Node 24 with synthetic events and a stub player. On November 10, 2026 I ran the ledger and its wiring as written against a live AIVAX Voice Session on @openai/gpt-realtime-2.1-mini, with a text prompt and a stub player: the reply streamed response.output_audio.delta chunks, a simulated barge-in after 48,000 samples received and 24,000 played sent audio_end_ms: 1000, and the session answered conversation.item.truncated with that value; an offset past the end of the audio returned an error event ("Audio content of 700ms is already shorter than 600000ms") with code invalid_value, and the wiring's pending map matched it to the truncate that caused it. The ledger and the matching logic also ran in Node against synthetic events, with and without an event_id on the error. No audio device was used, so playback latency is not measured. No call went to the OpenAI Realtime API, and I did not run the vercel/ai code. I found no documentation of defaults for create_response, interrupt_response or noise reduction, of what the server does with chunks that arrive after a truncate, or of how a cancelled response is billed. The Deepgram report is one developer's account that I did not test.