Skip to content

Recipe: Realtime Voice Session

Goal

Hold a speech-to-speech conversation with a model: stream audio in, consume audio and transcripts as they are produced, and read the session's cost when it ends.

This is the session-oriented surface. For one-shot text-to-speech or transcription, use Multimedia instead — you do not need a live connection for those.

Prerequisites

  • OPENAI_API_KEY for gpt-realtime, or GOOGLE_GEMINI_API_KEY for gemini-3.1-flash-live-preview. Keys are read from .env at the repo root.
  • No extra dependency: both adapters go through their vendor SDK's own realtime client.

The examples below name the two long-standing rows. Both now carry a deprecation record — gpt-realtime retires 2027-01-20 in favour of gpt-realtime-2.1, and gemini-3.1-flash-live-preview is a legacy preview whose recommended replacement is gemini-3.8-live — and every example works unchanged on the newer rows, which is the point of a registry-driven adapter. Check the model table before pinning one.

One turn, start to finish

The smallest complete session — open, speak, listen, close:

import asyncio
from doc_processing_toolkit.realtime import open_realtime_session

async def main():
    async with await open_realtime_session(
        "gpt-realtime",
        voice="marin",
        instructions="You are terse. Answer in at most three words.",
        output_format="audio/pcm",
    ) as session:
        await session.send_text("Say the word hello, and nothing else.")

        audio = bytearray()
        transcript = []
        async for event in session.events():
            if event.type == "audio_delta":
                audio.extend(event.audio or b"")
            elif event.type == "transcript_delta" and event.role == "assistant":
                transcript.append(event.text or "")
            elif event.type == "error":
                raise RuntimeError(event.error)
            elif event.type == "response_complete":
                break

    print("".join(transcript))            # "Hello"
    print(len(audio), "bytes of PCM")
    print(f"${session.cost:.4f}")         # final after the context manager closes

asyncio.run(main())

open_realtime_session is a coroutine that returns an already-connected session, which is why the context manager reads async with await .... The adapter is chosen from the model's provider in the registry, so you never name one.

Always bound your event loop

session.events() runs until the session closes. Break on response_complete when you want one turn, and consider a maximum-event ceiling so a stalled stream fails loudly instead of hanging — the live tests use 400.

response_complete means "the model finished answering" on every model, including one that called a tool first. On gemini-3.8-live-extended-thinking the turn boundary arrives separately, as a turn_complete event, because that model keeps reasoning past it — see Realtime Voice.

Give the session tools

Pass tools= and the toolkit runs the model's tool calls for you — same loader, same MCP path, same hook events as an ordinary agent run — and answers them on the socket:

session = await open_realtime_session(
    "gpt-realtime-2.1-mini",
    tools=["tools/my_tools.py"],
    max_tool_rounds=8,          # per session, default 16
    tool_timeout_seconds=20,    # per call, default 30
)

You will see tool_call and tool_result events go by; you can ignore both if you only want the answer. Every ceiling, veto, timeout and tool crash is answered with an error result, because a realtime model left waiting on an unanswered tool slot simply goes quiet. See Tools in a realtime session for the per-provider mechanics.

Save the reply as a playable WAV

Both providers emit raw 24 kHz mono PCM16 — headerless bytes, not a playable file. Wrap them with the stdlib:

import wave
from doc_processing_toolkit.realtime import GOOGLE_LIVE_OUTPUT_SAMPLE_RATE

def write_wav(path: str, pcm: bytes, sample_rate: int = GOOGLE_LIVE_OUTPUT_SAMPLE_RATE) -> None:
    with wave.open(path, "wb") as fh:
        fh.setnchannels(1)
        fh.setsampwidth(2)              # 16-bit
        fh.setframerate(sample_rate)
        fh.writeframes(pcm)

write_wav("reply.wav", bytes(audio))

Streaming microphone audio in

send_audio takes little-endian 16-bit mono PCM. Feed it in chunks as they arrive from your capture device; with the provider's default server-side voice activity detection, the model decides when your turn ended and answers on its own.

from doc_processing_toolkit.realtime import (
    OPENAI_REALTIME_SAMPLE_RATE,       # 24000
    GOOGLE_LIVE_INPUT_SAMPLE_RATE,     # 16000
)

session = await open_realtime_session("gpt-realtime", voice="marin")
consumer = asyncio.create_task(drain_events(session))

for chunk in microphone_chunks():        # bytes, 20-100 ms each works well
    await session.send_audio(chunk)

The two providers want different input rates

OpenAI Realtime expects 24 kHz in; Gemini Live expects 16 kHz in. send_audio forwards your bytes unchanged and neither provider resamples for you, so audio captured at the wrong rate is transcribed as gibberish rather than rejected. Read the rate from the constants above, or from the registry's input_audio_sample_rate when you support both models.

Run the event consumer as a separate task, as above — sending and receiving are independent directions, and awaiting events() inline would block your capture loop.

Show the model a picture

send_image attaches a still to the conversation; send_video_frame offers a frame from a continuous capture and is rate-gated so a capture loop cannot multiply your bill.

await session.send_image(chart_png)                     # never throttled
await session.send_text("What is the trend in this chart?")

async for frame in screen_capture():                    # 30 fps in
    await session.send_video_frame(frame)               # ~1 fps on the wire
print(session.frames_sent, session.frames_dropped)

Pass encoded bytes, not raw pixels — the container is identified from the payload itself and an unidentifiable one is refused rather than guessed at. A frame does not ask the model to answer; it is context for whatever you say next. max_video_fps (default 1, 0 to disable the gate) is settable through open_realtime_session or configure. The full rules, including what happens to OpenAI's detail hint on Gemini, are in Showing the model what you are looking at.

Transcribe the caller's own audio

By default only the model's speech is transcribed. Ask the provider to transcribe your side too, and it arrives on the same event stream tagged role="user":

session.configure(input_transcription=True)

async for event in session.events():
    if event.type == "transcript_delta":
        who = "you" if event.role == "user" else "model"
        print(f"[{who}] {event.text}")

On OpenAI, input_transcription=True uses the row's default_input_transcription_model, which is gpt-transcribe on every conversational row. To steer that model, pass a dict of guidance fields. The dict is merged over the default, and language and languages are alternatives:

session.configure(
    input_transcription={"language": "de", "keywords": ["IBAN", "Rechnungsnummer"]},
    noise_reduction="far_field",      # laptop microphone; "near_field" for a headset, False for off
)

Manual turn control

Disable turn detection when your application decides where turns end — push-to-talk, for example. On OpenAI you then close each input turn yourself:

session = await open_realtime_session(
    "gpt-realtime",
    turn_detection={"disabled": True},
)

for chunk in push_to_talk_chunks():
    await session.send_audio(chunk)

await session.commit_audio()      # ends the turn and requests a response

commit_audio(create_response=False) closes the turn without asking for an answer yet. With server VAD left on, you never call this — the provider commits turns itself.

The same loop works on Gemini Live. The first send_audio() of a turn opens it with activity_start, and commit_audio() sends activity_end, which makes the model answer:

session = await open_realtime_session(
    "gemini-3.1-flash-live-preview",
    turn_detection={"disabled": True},
)

for chunk in push_to_talk_chunks_16k():
    await session.send_audio(chunk)   # activity_start goes out before the first chunk

await session.commit_audio()          # activity_end -> the model answers this turn

With Gemini's automatic detection left on, commit_audio() sends audio_stream_end instead. Call it when the microphone pauses so the server flushes what it has buffered.

Captions instead of a conversation

When you want the words and not a spoken reply, open a transcription session rather than muting the model. There is no model turn on that socket at all:

from doc_processing_toolkit.realtime import open_transcription_session

session = await open_transcription_session("gpt-transcribe")
try:
    for chunk in microphone_chunks():
        await session.send_audio(chunk)
    await session.commit_audio()
    async for event in session.events():
        if event.type == "transcript_complete":
            print(event.item_id, event.text)
finally:
    await session.close()       # drains what is still in flight first

Correlate on item_id, not on arrival order — items complete out of order. On gemini-3.5-transcribe-live the interim events are the additions to a cumulative snapshot and the settled caption only arrives once commit_audio() (or close()) has marked the end of the audio, so call it at each utterance boundary. See Live transcription.

Finer turn_detection settings are a neutral dict that is translated per provider, not forwarded: a key the target provider cannot express is dropped with a warning naming the nearest control it does have. The full mapping is in Realtime Voice.

Interpret a speaker into another language

A translation session is an interpreter, not a conversation: no voice to choose, no instructions, no tools, no turns. You stream speech in and get translated speech plus both transcripts back while the speaker is still talking.

from doc_processing_toolkit.realtime import open_translation_session

session = await open_translation_session(
    "gemini-3.5-live-translate-preview",     # 16 kHz in; OpenAI's row wants 24 kHz
    target_language="en",                    # required — there is no safe default
)
consumer = asyncio.create_task(drain_events(session))   # audio_delta -> speaker
try:
    for chunk in japanese_microphone():
        await session.send_audio(chunk)
finally:
    await session.close()                    # flushes, then drains the tail

print(session.source_transcript)             # what the speaker said
print(session.translated_transcript)         # the interpretation
print(session.detected_source_languages)     # ['ja'] — detected, never set

Streaming a file? Keep sending after the last sample

Both transcripts advance only while input audio keeps arriving. A loop that pushes a clip and stops sending loses the tail of both transcripts — and, on Gemini, the last usage report. Follow the clip with a second or two of paced silence, exactly as a microphone would, before you close:

silence = b"\x00\x00" * 16_000              # 1 s of 16 kHz mono PCM16
for chunk in clip_chunks():
    await session.send_audio(chunk)
    await asyncio.sleep(0.02)               # pace it; do not blast the socket
for _ in range(2):
    await session.send_audio(silence)
    await asyncio.sleep(1.0)

The target language is validated against the model's own published list before the socket opens, in the vendor's own spelling, so "PT-br" goes out as pt-BR and a wrong code fails immediately with a "did you mean" rather than being ignored. source_language= is accepted and ignored with a warning — neither provider has a field for it. Closing drains what the server is still flushing; on OpenAI that took 3.9 seconds in a live run, which is why that close has its own 10-second budget (provider_options={"close_drain_seconds": ...}). Full behaviour: Live translation.

GPT-Live, with a reasoning backend

gpt-live-1 is a different protocol on /v1/live/*, not another realtime model. Three habits from the sections above do not carry over: there is no audio commit, no response_complete to break on, and no item_id on a transcript delta.

from doc_processing_toolkit.realtime_gpt_live import open_live_session

session = await open_live_session(
    "gpt-live-1",
    voice="marin",
    instructions="Be concise. Delegate order questions to the backend.",
    tools=["tools/order_tools.py"],                          # run locally, for the backend
    provider_options={"backend_model": "gpt-5.6-luna"},      # without this, tools= is refused
)
try:
    async for event in session.events():
        if event.type == "audio_delta":
            speaker.write(event.audio)
        elif event.type == "transcript_delta":
            print(event.role, event.text)                    # event.item_id is always None
        elif event.type == "session_closed":
            break                                            # the only terminal event
finally:
    await session.close()

print(session.billed_audio_seconds, session.cost)
print(session.cost_excludes)        # why that number is not the whole bill
print(session.delegated_usage)      # backend responses, for you to price

Drive the loop from your playback queue or the transcript and end the session when your application decides — nothing on this protocol marks the end of a spoken answer. The bill is wall clock at $0.05/minute, silences and backend thinking included, so bound the session yourself. Steer a running conversation with session.append_instructions(...) (there is no cancel — the model is full duplex and interrupts itself), and note that session.cost covers the voice session only: a delegated backend is billed separately on its own rates. Full behaviour: GPT-Live.

Interrupt, and tell the model what was heard

await session.cancel_response()                    # stop mid-answer
await session.truncate_response(played_ms=1_800)   # what the listener actually heard

Without the truncation the provider still believes it said the whole answer, and the next turn is grounded in words nobody heard. session.emitted_audio_ms is the toolkit's own count of the audio it handed you for the current assistant item and is used when you pass no played_ms — pass your own if you buffer before playback. OpenAI has both controls; Gemini Live has no truncation message, and its cancel exists only on the 3.8 rows.

Gemini reconnects are already handled

Gemini Live caps session duration server-side (15 minutes, 2 with video). The adapter hides that: it enables session resumption, records each handle the server issues, reacts to the server's goAway warning, and reconnects with the stored handle. Your events() iterator does not stop — you just see one extra event at the seam:

async for event in session.events():
    if event.type == "session_resumed":
        logger.info("Reconnected transparently; conversation continues.")
        continue

You only need to handle it if you want to observe the seam. Bound the retries with provider_options={"max_reconnects": 3} if the default of 10 is more patience than your application wants; reconnects back off exponentially, capped at 8 seconds.

Export the session and seed the next one

import json

record = session.export()                      # JSON-clean; final after close()
json.dump(record, open("call-1.json", "w"))
print(record["transcript"])                    # [{"role": "user", "text": "...", ...}, {"role": "assistant", ...}]

seed = [{"role": e["role"], "text": e["text"]} for e in record["transcript"]]
async with await open_realtime_session(
    "gpt-realtime",
    voice="marin",
    output_format="audio/pcm",
    provider_options={"initial_items": seed},  # the model starts knowing the earlier turns
) as resumed:
    await resumed.send_text("What was my name again?")
    async for event in resumed.events():
        if event.type == "response_complete":
            break

The same option works on a Gemini Live row; GPT-Live takes provider_options={"initial_input": [{"role": ..., "content": ...}]} for the same purpose. See Exporting a session and seeding the next one.

Read the cost

Usage accumulates per modality in both directions and is priced under the registry's audio_realtime schema. Both are live during the session and final after close():

usage = session.usage
print(usage.input_audio_tokens, usage.output_audio_tokens)
print(usage.input_image_tokens)      # stills and frames, billed at the row's input_image rate
print(usage.cached_input_tokens)     # a SUBSET of total_input_tokens, never an addition
print(session.usage.as_dict())       # every counter plus derived totals
print(session.cost, session.cost_estimated)

response_complete events also carry a cumulative usage dict and cost float, which is the cheapest way to drive a running cost display without touching the session object.

For reference, the live smoke tests bill roughly $0.0045 for a one-word gpt-realtime turn (158,400 bytes of PCM) and $0.0010 for the same turn on gemini-3.1-flash-live-preview (43,710 bytes). Audio tokens dominate — text is a rounding error at these sizes.

Not every session is token-priced. gpt-live-1 and gpt-realtime-translate are billed by the clock, so session.usage stays empty and the number to watch is session.billed_audio_seconds; read session.cost_excludes before treating session.cost as the whole charge. See Sessions billed by the second.

Validate before you connect

Options are checked against the model's registry row before any network activity, so a bad voice fails immediately and names what the model does support:

from doc_processing_toolkit.realtime import RealtimeValidationError

try:
    await open_realtime_session("gpt-realtime", voice="Kore")   # a Gemini voice
except RealtimeValidationError as exc:
    print(exc)      # names the supported OpenAI voices

Voice names are opaque provider strings — gpt-realtime has 10, gemini-3.1-flash-live-preview has 30, and there is no cross-provider enum. Errors the provider reports mid-session arrive as error events instead, so your consumer loop decides whether to continue or break.

Testing without an API key

The unit suite drives both adapters over mock transports (tests/unit/test_realtime.py), covering lifecycle, event mapping, usage accumulation, cost, resumption handles, and reconnects. The live sessions live in tests/integration/test_realtime_live.py and skip without keys. See Testing.

  • Realtime Voice — the full event vocabulary and provider behavior
  • Multimedia — one-shot TTS, STT, and image generation
  • Cost Tracking — pricing schemas and the cached-tokens convention