Bidirectional Audio Streaming
On this page
Updated November 2, 2026 — Audio models. Several models in this course are deprecated:
tts-1,tts-1-hd, andgpt-4o-mini-ttswill stop working on January 6, 2027 (the indicated replacement isgpt-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/translationson 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.
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.committo 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.gatherenables simultaneous capture and playback on the same WebSocket