Realtime Engine -- High-Level Architecture
The server runs as a handler thread inside the existing pipeline. A FastAPI/uvicorn instance serves the WebSocket endpoint at /v1/realtime, while the same queue-driven handler chain (VAD, STT, LLM, TTS) processes audio in parallel threads.
flowchart LR
subgraph client [Client]
WS["WebSocket /v1/realtime"]
end
subgraph server [Realtime Server]
Router["WebSocket Router"]
Service["RealtimeService"]
Config["RuntimeConfig"]
end
subgraph pipeline [Pipeline Threads]
VAD["VAD"]
STT["STT"]
TN["TranscriptionNotifier"]
LLM["LLM"]
Proc["LMOutputProcessor"]
TTS["TTS"]
end
WS -- "client events (JSON)" --> Router
Router -- "parse / dispatch" --> Service
Service -- "PCM chunks" --> VAD
Service -- "session config" --> Config
Config -. "read by" .-> VAD & LLM & TTS
VAD -- "speech segments" --> STT
STT -- "transcript" --> TN
TN -- "transcript + notify" --> LLM
LLM -- "text + tools" --> Proc
Proc -- "clean text" --> TTS
TTS -- "PCM audio" --> Router
Proc -- "assistant_text / tools" --> Router
VAD -- "speech_started/stopped" --> Router
TN -- "transcription events" --> Router
Router -- "server events (JSON)" --> WS
Key flow:
- Inbound audio: Client sends
input_audio_buffer.appendwith base64 PCM.RealtimeServicedecodes, resamples to 16 kHz, splits into 512-sample chunks, and puts them on therecv_audio_chunks_queuefor VAD. - Speech detection: VAD detects speech boundaries and emits
speech_started/speech_stoppedevents on thetext_output_queue. Full utterance audio goes to STT. - Transcription: STT output passes through
TranscriptionNotifier, which taps the transcript fortranscription.delta/transcription.completedevents before forwarding to the LLM. - Generation: The LLM generates text (and optional tool calls).
LMOutputProcessorsplits the output: clean text goes to TTS, andassistant_text+ tool call dicts go to thetext_output_queue. - Outbound audio: TTS writes PCM chunks to
send_audio_chunks_queue. The router's async_send_loopdrains both queues, encoding PCM asresponse.output_audio.deltaevents and translating internal messages into protocol events. - Session config:
session.updateevents deep-merge intoRuntimeConfig, which is a shared Pydantic model read by VAD (turn detection thresholds), LLM (instructions, tools), and TTS (voice) at processing time.
Supported OpenAI Realtime Events
Client -> Server
| Event | Description |
|---|---|
input_audio_buffer.append |
Stream base64 PCM audio. Decoded, resampled to 16 kHz, and chunked for the VAD. |
session.update |
Deep-merge session config (instructions, tools, voice, turn detection, audio format). |
conversation.item.create |
Inject input_text or function_call_output into the LLM context without triggering generation. |
response.create |
Trigger LLM generation. Supports per-response instructions and tool_choice overrides. |
response.cancel |
Cancel the in-progress response and re-enable listening. |
Server -> Client
| Event | Description |
|---|---|
session.created |
Sent on connection with current session config. |
error |
Protocol errors (session_limit_reached, unknown_or_invalid_event, invalid_session_type, conversation_already_has_active_response, etc.) |
input_audio_buffer.speech_started |
VAD detected user speech. |
input_audio_buffer.speech_stopped |
End of user speech segment. |
conversation.item.created |
Acknowledges injected input_text from conversation.item.create. |
conversation.item.input_audio_transcription.delta |
Streaming partial transcript (when live transcription is enabled). |
conversation.item.input_audio_transcription.completed |
Final transcript for the user turn (with duration usage). |
response.created |
Emitted on the first outbound audio chunk (response is in_progress). |
response.output_audio.delta |
Base64 PCM audio chunk from TTS. |
response.output_audio.done |
Audio stream complete for the current output item. |
response.output_audio_transcript.done |
Full assistant text transcript for the turn. |
response.function_call_arguments.done |
Tool call with call_id, name, and JSON arguments. |
response.done |
Response finished (completed, cancelled with reason turn_detected or client_cancelled). |
Tool Calling Design
Tool calling works through two distinct paths depending on the LLM backend, but both converge to the same wire protocol for the client.
Local LLM path (LanguageModelHandler -- transformers / mlx-lm)
Tools defined in session.update are converted to FunctionTool objects. Each tool's JSON Schema parameters are turned into a Python inspect.Signature (via signature_from_schema), and to_code_prompt() renders a human-readable def name(...): """docstring""" block.
These tool signatures are injected into the system prompt using a Jinja2 template (tool_prompt.py) that instructs the model to wrap tool calls in <code>...</code> delimiters:
<code>
function_name(arg_name_1=value1, arg_name_2='string_value')
</code>
After generation, _extract_tools uses a regex to find <code> blocks, then extract_function_calls_from_text parses each name(kwargs) call and validates it against registered tools. Valid calls become ResponseFunctionToolCall dicts with generated call_ids.
OpenAI API path (ResponsesApiModelHandler)
Tools are passed natively as the tools= parameter to client.responses.create. The API returns structured function_call items directly -- no prompt engineering or regex parsing needed. Per-response tool_choice overrides from response.create are supported.
Common output path
Both handlers yield (text, language_code, tools) tuples. LMOutputProcessor forwards clean text to TTS and puts {"type": "assistant_text", "text": ..., "tools": [...]} on the text_output_queue. The router's _send_loop translates these into:
response.output_audio_transcript.donefor the textresponse.function_call_arguments.donefor each tool call
Tool result flow
- Client executes the tool and sends
conversation.item.createwithtype: "function_call_output"andoutput: "<result>" RealtimeServiceappends the tool output to the chat context and emitsconversation.item.created; this does not trigger generation.- If the tool result needs to be spoken to the user, such as camera/search/data results, the client sends
response.createto trigger follow-up generation. - For fire-and-forget robot actions such as dance, emotion, head movement, stop, or idle tools, the client can stop after
conversation.item.created; the assistant should already have spoken the natural lead-in before the tool call.
Interruption Handling
Barge-in (user speaks while the assistant is playing audio) is handled cooperatively between the VAD, the _send_loop, and the LLM/TTS handlers via a shared CancelScope object (cancel_scope.py).
CancelScope design
CancelScope replaces the old two-signal pattern (cancel_response Event + discard_stale_output boolean) with a single object that manages:
- Generation counter (
cancel_scope.generation): pipeline threads (LLM, TTS) capture the current generation at the start of each response and checkcancel_scope.is_stale(gen)on every streaming token. Whencancel()is called, the generation increments and all prior generations become stale -- no timing games required. - Discard flag (
cancel_scope.discarding): set bycancel(), checked by the async_send_loopto drop output from superseded generations that arrives betweencancel()andresponse_done(). Cleared byresponse_done(generation)(only when the sentinel's generation matches the discarded or current one -- sentinels from unrelated older generations are ignored), bynew_response()on an explicitresponse.create, or byreset()on session claim/release.
Pipeline output is generation-tagged: AudioOutput chunks and AssistantTextEvents carry a cancel_generation field stamped by the handler that produced them. The send loop's _generation_is_discardable drops an item if its generation is stale, or if discarding is set and the item is not from the current generation. Output from the current generation always passes through, so a fresh response is never swallowed by a lingering discard window (e.g. a superseded speculative turn whose TTS never emitted a __RESPONSE_DONE__ sentinel).
sequenceDiagram
participant User
participant VAD
participant SendLoop as _send_loop
participant LLM
participant TTS
participant Client
Note over TTS,Client: Response active or pending (in_response / response_pending)
User->>VAD: speaks
VAD->>SendLoop: speech_started on text_output_queue
SendLoop->>Client: input_audio_buffer.speech_started
SendLoop->>Client: response.done (status=cancelled, reason=turn_detected)
SendLoop->>SendLoop: cancel_scope.cancel() (gen++ & discarding=True)
SendLoop->>SendLoop: flush output_queue + text_output_queue
SendLoop->>SendLoop: response_playing.clear()
LLM->>LLM: is_stale(gen) → True, aborts generation
TTS->>TTS: is_stale(gen) → True, aborts generation
TTS->>SendLoop: __RESPONSE_DONE__ (tagged with gen)
SendLoop->>SendLoop: cancel_scope.response_done(gen) (discarding=False)
Note over VAD,Client: Pipeline is now processing the new user utterance
Step by step:
- VAD detects speech: puts a
SpeechStartedEventontext_output_queue. _send_loopprocesses text events first (priority over audio): translatesspeech_startedinto protocol events. If an active response was in progress,RealtimeService.dispatch_pipeline_eventemitsresponse.output_audio.done+response.donewithstatus="cancelled"andreason="turn_detected".- Cancel + queue flush: if a response is active (
in_response) or pending (response_pending-- acceptedresponse.create, no audio yet), and interrupts are enabled (see step 4), the send loop callscancel_scope.cancel()(increments generation, enables discard), clearsresponse_pending, drainsoutput_queue(preserving__RESPONSE_DONE__sentinels) andtext_output_queue(preserving user-side events:speech_stopped, partial/completed transcriptions, token usage), then clearsresponse_playing. - Interrupt gating: the cancel only fires if the
SpeechStartedEvent.interrupt_responseflag is set and the session config allows it (turn_detection.interrupt_response, read viaRuntimeConfig.interrupt_response_enabled, default true). When disabled, user speech during a response is transcribed but the response keeps playing. - LLM/TTS cancellation: handlers capture
gen = cancel_scope.generationat the start of each response and checkcancel_scope.is_stale(gen)on every streaming token, aborting early when stale. - Discard guard: while
cancel_scope.discardingis True, the send loop drops audio chunks and assistant text whosecancel_generationis not current (see_generation_is_discardableabove). The guard clears when a__RESPONSE_DONE__with a matching generation arrives (viacancel_scope.response_done(gen)), or when an explicitresponse.createstarts a new response (cancel_scope.new_response()). - Client-initiated cancel:
response.cancelcallscancel_scope.cancel()(only if a response was active), flushes both queues with the same preservation rules, triggersfinish_response(status="cancelled", reason="client_cancelled"), re-enablesshould_listen, and clearsresponse_playing. - Spurious cancel safety: if no response is active,
cancel_scope.cancel()is not called, preventing the discard guard from being set without a__RESPONSE_DONE__to clear it.
Testing
Local LLM with Transformers
uv run speech-to-speech \
--mode realtime \
--stt parakeet-tdt \
--llm_backend transformers \
--tts kokoro \
--model_name "Qwen/Qwen3-4B-Instruct-2507" \
--llm_device mps \
--llm_torch_dtype float16 \
--enable_live_transcription
Local LLM with MLX-LM
uv run speech-to-speech \
--mode realtime \
--stt parakeet-tdt \
--llm_backend mlx-lm \
--tts kokoro \
--model_name "mlx-community/Qwen3-4B-Instruct-2507-bf16" \
--llm_device mps \
--llm_torch_dtype float16 \
--enable_live_transcription
Remote LLM with OpenAI-compatible API
uv run speech-to-speech \
--mode realtime \
--stt parakeet-tdt \
--llm_backend responses-api \
--tts kokoro \
--model_name "openai/gpt-oss-20b:groq" \
--responses_api_base_url "https://router.huggingface.co/v1" \
--responses_api_api_key "$HF_TOKEN" \
--responses_api_stream \
--enable_live_transcription