Skip to main content
Lesson 3 of 18 · Realtime API and Voice Agents

Bidirectional Audio Streaming

Lesson 3 of 18 Updated November 2, 2026 Developer
On this page
  1. Bidirectional Audio Streaming
  2. PCM16 Audio Format
  3. Sending Audio: Microphone Capture
  4. Receiving Response Audio
  5. Complete Application: Voice Conversation
  6. Key Takeaways

Updated November 2, 2026 — Audio models. Several models in this course are deprecated: tts-1, tts-1-hd, and gpt-4o-mini-tts will stop working on January 6, 2027 (the indicated replacement is gpt-realtime-2.1-mini, which the OpenAI TTS guide states is not supported by /v1/audio/speech: speech synthesis migration moves to the Realtime API); gpt-realtime, gpt-4o-realtime, and legacy audio models on January 20, 2027 (gpt-realtime-2.1, gpt-realtime-2.1-mini, gpt-audio-1.5); whisper-1, gpt-4o-transcribe, and /v1/audio/translations on February 26, 2027 (gpt-transcribe, gpt-live-transcribe, v1/realtime/translations). For full-duplex voice, GPT-Live 1 has been available in the API since September 10, 2026.

Bidirectional Audio Streaming

Bidirectional audio streaming is the core of the Realtime API. You continuously send audio chunks from the microphone, and the model continuously returns response audio chunks. These two streams are independent and can operate simultaneously—which is what makes the conversation feel natural.

PCM16 Audio Format

The Realtime API uses 24 kHz mono 16-bit PCM format. This is a raw, uncompressed format: each sample is a 16-bit signed integer. This choice prioritizes low latency over bandwidth efficiency.

24 kHz
Sampling rate
16 bit
Sample depth
Mono
Single channel
48 KB/s
Raw audio bitrate

Audio chunks are sent Base64-encoded within JSON events. Each chunk typically represents 20 to 100 ms of audio.

Sending Audio: Microphone Capture

Here is how to capture the microphone and stream the audio:

import asyncio
import sounddevice as sd
import numpy as np
import base64
import json

SAMPLE_RATE = 24000
CHUNK_DURATION_MS = 100
CHUNK_SIZE = int(SAMPLE_RATE * CHUNK_DURATION_MS / 1000)

async def stream_microphone(ws):
    """Capture the microphone and send chunks to the Realtime API."""
    loop = asyncio.get_event_loop()
    audio_queue = asyncio.Queue()

    def audio_callback(indata, frames, time_info, status):
        if status:
            print(f"Audio warning: {status}")
        # Convert to int16 and enqueue
        pcm_data = (indata[:, 0] * 32767).astype(np.int16)
        loop.call_soon_threadsafe(audio_queue.put_nowait, pcm_data)

    # Start capture
    stream = sd.InputStream(
        samplerate=SAMPLE_RATE,
        channels=1,
        dtype="float32",
        blocksize=CHUNK_SIZE,
        callback=audio_callback
    )

    with stream:
        print("Microphone active — speak now...")
        while True:
            pcm_data = await audio_queue.get()
            audio_b64 = base64.b64encode(pcm_data.tobytes()).decode("utf-8")

            await ws.send(json.dumps({
                "type": "input_audio_buffer.append",
                "audio": audio_b64
            }))

The Server-Side Audio Buffer

Chunks sent via input_audio_buffer.append accumulate in a buffer on the server. This buffer is processed in two ways:

  • Automatic VAD mode (server_vad): The server detects when you stop speaking and automatically commits the buffer.
  • Manual mode: You explicitly send input_audio_buffer.commit to trigger processing.
# Manually commit the audio buffer
await ws.send(json.dumps({
    "type": "input_audio_buffer.commit"
}))

# Or clear the buffer without processing it
await ws.send(json.dumps({
    "type": "input_audio_buffer.clear"
}))

Receiving Response Audio

Audio responses arrive in chunks via response.output_audio.delta events. You must decode and play them continuously:

import pyaudio

async def play_response_audio(ws):
    """Receive and play response audio."""
    pa = pyaudio.PyAudio()
    player = pa.open(
        format=pyaudio.paInt16,
        channels=1,
        rate=SAMPLE_RATE,
        output=True,
        frames_per_buffer=CHUNK_SIZE
    )

    try:
        async for raw_message in ws:
            event = json.loads(raw_message)

            if event["type"] == "response.output_audio.delta":
                # Decode the Base64 audio chunk
                audio_bytes = base64.b64decode(event["delta"])
                player.write(audio_bytes)

            elif event["type"] == "response.output_audio.done":
                print("Audio response finished")

            elif event["type"] == "response.output_audio_transcript.delta":
                # Text transcription of the response
                print(event["delta"], end="", flush=True)

    finally:
        player.stop_stream()
        player.close()
        pa.terminate()

Complete Application: Voice Conversation

Here is how to assemble capture and playback for a full conversation:

async def conversation_vocale():
    headers = {
        "Authorization": f"Bearer {OPENAI_API_KEY}"
    }

    async with websockets.connect(URL, additional_headers=headers) as ws:
        # Wait for session.created
        await ws.recv()

        # Configure the session
        await ws.send(json.dumps({
            "type": "session.update",
            "session": {
                "type": "realtime",
                "output_modalities": ["audio"],
                "instructions": "You are a voice assistant. Respond in English.",
                "audio": {
                    "input": {
                        "format": {"type": "audio/pcm", "rate": 24000},
                        "turn_detection": {
                            "type": "server_vad",
                            "threshold": 0.5,
                            "silence_duration_ms": 700
                        }
                    },
                    "output": {
                        "format": {"type": "audio/pcm", "rate": 24000},
                        "voice": "marin"
                    }
                }
            }
        }))
        await ws.recv()  # session.updated

        # Launch capture and playback in parallel
        await asyncio.gather(
            stream_microphone(ws),
            play_response_audio(ws)
        )

asyncio.run(conversation_vocale())

Managing Concurrent Streams

The challenge with bidirectional streaming is that you are sending and receiving simultaneously on the same WebSocket. asyncio.gather launches both coroutines in parallel, but they share the same WebSocket object. The websockets library handles this concurrency correctly.

Key Takeaways

  • Audio is transmitted as 24 kHz mono 16-bit PCM, Base64-encoded in JSON events
  • Chunks are typically 20 to 100 ms of audio for a good latency/overhead trade-off
  • The server-side audio buffer accumulates chunks until commit (automatic in VAD mode)
  • Response playback happens chunk-by-chunk via response.output_audio.delta
  • asyncio.gather enables simultaneous capture and playback on the same WebSocket