Aquatint of a long banquet table in a dark blue hall under arched windows: three cloches lit by an amber lamp on the left, one chair pushed back from the table, and a row of closed cloches running off to the right, with a moon in the far window
All posts

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

The events involved in a WebSocket interruption, who sends them, and what the client does with each
EventSent byWhat it meansWhat the client does
input_audio_buffer.speech_startedServerSpeech detected in the input buffer in server_vad mode; the reference says the client may use it to interrupt playbackFlush the player. The item_id in this event is the user's item, not an assistant item.
response.cancelClientCancels an in-progress response; the server answers with response.done and status cancelledSend 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 cancelledServerPer the reference, status_details.reason is turn_detected when VAD cancelled it and client_cancelled when you didRoute on this event. The guide mentions a response.cancelled event that the reference does not define.
response.output_audio.doneServerNo 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.

A playback ledger counts samples received and played for each item, and an interruption turns them into truncate events Top row, while audio plays: audio delta events feed the ledger's received count and the player queue, and the player reports played samples back to the ledger. Bottom row, when speech starts: the client flushes the player, cancels the response if it is still generating, sends one truncate per item with audio_end_ms equal to samples played divided by 24, and receives a truncated event or an error that names the event id. While audio plays response.output_audio.delta base64 PCM, one item id ledger.received samples per item player queue decode and schedule ledger.played frames the worklet consumed When speech_started arrives flush the player stop sound, final played count response.cancel only if still generating conversation.item.truncate audio_end_ms = floor(played / 24) conversation.item.truncated or an error for that truncate
While audio plays, the client counts samples received and samples played for each item. On speech_started it flushes the player, cancels the response if it is still generating, and sends one truncate per item that was not heard in full, with audio_end_ms equal to the samples played divided by 24.

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.

Truncate bugs reported in public Realtime clients, their status, and the ledger guard that covers each
IssueWhat the client didStatusLedger guard
openai-agents-python #2370Sent 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 itselfFixed in #2385, merged 2026-02-03Nothing is sent unless an interruption happens, and the offset never passes the samples received
openai-agents-python #5190A 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 itFixed in #5193, merged 2026-09-27An item that is done and played in full is skipped
openai-agents-python #4456An interrupt in the first Windows clock tick measured 0 ms elapsed and skipped the truncateFixed in #4457, merged 2026-08-16Zero is a valid offset, so an interruption before any audio played still truncates at 0
vercel/ai #22855With two items queued, the playing item's offset was applied to the newest item, and the playing item was never truncatedIssue closed 2026-10-13; the fix #22883 was closed unmerged. On 2026-10-27 realtime-session.ts on main still held a single currentResponseItemIdCounters per item, and one truncate for every item with unheard audio
openclaw #138592On 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-10The offset is samples played for that item, never elapsed time
openai-agents-python #5068A reconnect on the same model object kept the old item ids, so the first speech_started truncated an item the new session had never seenFixed in #5069, merged 2026-09-25reset() 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.