feat: RealtimeVoiceChannel — speech-to-speech over Twilio Media Streams (POC)#82
feat: RealtimeVoiceChannel — speech-to-speech over Twilio Media Streams (POC)#82xinghaohuang91 wants to merge 5 commits into
Conversation
…reams) POC of an audio-native voice path: bridge Twilio Media Streams directly to the OpenAI Realtime API, instead of ConversationRelay's text protocol. - RealtimeVoiceChannel: relays u-law audio both ways, no text turn; handles barge-in (truncate in-flight reply + clear Twilio buffer on speech_started) - RealtimeVoiceChannelConfig: model / voice / instructions / welcome greeting - generate_stream_twiml: <Connect><Stream> TwiML - RealtimeVoiceServer: dedicated FastAPI host (TwiML + WS routes) - example app, tests, REALTIME_VOICE.md with a mermaid flow diagram - new optional 'realtime' extra (websockets) Memory injection is intentionally out of scope for this first version. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Note
Copilot couldn't run its full agentic review because no GitHub Actions runner was available. Make sure your repository has a runner available to run Copilot's review, or add a copilot-setup-steps.yml file specifying one with the runs-on attribute. See the docs for more details.
Adds a POC “RealtimeVoiceChannel” that bridges Twilio Media Streams (u-law audio) to the OpenAI Realtime WebSocket API for low-latency speech-to-speech voice calls, plus a dedicated FastAPI server endpoint and supporting docs/tests.
Changes:
- Introduces
RealtimeVoiceChannel+ config + TwiML generator for<Connect><Stream>. - Adds
RealtimeVoiceServer(FastAPI + uvicorn) exposingPOST /twiml-realtimeand a/voice-realtimeWebSocket bridge. - Adds tests, example script, and a new optional
realtimeextra (websockets).
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/test_realtime_voice_channel.py | Adds unit tests for TwiML generation, channel bridging behavior, and server route registration. |
| src/tac/server/realtime_server.py | New FastAPI server hosting TwiML + WebSocket endpoints for the realtime voice path. |
| src/tac/server/init.py | Exposes RealtimeVoiceServer via the server package API. |
| src/tac/channels/realtime/twiml.py | Adds TwiML generator for Twilio Media Streams <Connect><Stream>. |
| src/tac/channels/realtime/config.py | Adds Pydantic config for model/voice/instructions + telephony audio format constants. |
| src/tac/channels/realtime/channel.py | Implements the Twilio↔OpenAI dual-WebSocket audio relay with barge-in truncation. |
| src/tac/channels/realtime/init.py | Adds public exports for the realtime channel package. |
| src/tac/channels/init.py | Re-exports realtime channel symbols at the top-level channels package. |
| pyproject.toml | Adds realtime optional extra with websockets. |
| getting_started/examples/features/voice_realtime.py | Adds runnable example for the realtime voice server/channel. |
| REALTIME_VOICE.md | Adds POC documentation + sequence diagram and setup instructions. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if model_reader is not None: | ||
| model_reader.cancel() | ||
| if session.model_ws is not None: | ||
| try: | ||
| await session.model_ws.close() | ||
| except Exception: # pragma: no cover - best-effort cleanup | ||
| pass |
| await self._twilio_send( | ||
| session, {"event": "mark", "streamSid": session.stream_sid} | ||
| ) |
| @app.websocket(self.websocket_path) | ||
| async def realtime_websocket_endpoint(websocket: WebSocket) -> None: | ||
| """Bridge the Twilio Media Stream to the speech-to-speech model.""" | ||
| adapter = FastAPIWebSocketAdapter(websocket) | ||
| await channel.handle_websocket(adapter) |
| @app.post(self.twiml_path, dependencies=twiml_dependencies) | ||
| async def post_realtime_twiml(request: Request) -> Response: | ||
| """Return <Connect><Stream> TwiML pointing Twilio at the audio WebSocket.""" | ||
| twiml = channel.build_stream_twiml(websocket_url) | ||
| return Response(content=twiml, media_type="application/xml") |
| if not websocket_url or not websocket_url.strip(): | ||
| raise ValueError("generate_stream_twiml requires a non-empty websocket_url.") | ||
|
|
||
| response = VoiceResponse() | ||
|
|
||
| connect = Connect() | ||
|
|
||
| stream = connect.stream(url=websocket_url) |
|
The raw-audio bridge between Twilio Media Streams and a speech-to-speech model is a strong direction, especially because it avoids the STT -> text agent -> TTS chain that usually adds latency and failure points. One suggestion: add a per-call budget and fallback policy early, even for the POC. Realtime audio can become expensive fast, and many production voice flows only need the premium realtime model during the live turn while summaries, classification, and follow-up actions can be routed to cheaper text models afterward. I am testing an OpenAI-compatible multi-model API layer around official Chinese models, and voice-agent routing/cost boundaries are one of the areas I am watching closely. For this channel, is latency or audio-token cost the bigger blocker for moving beyond POC? |
- Add TACTool support to RealtimeVoiceChannel: tools are sent as session.tools and model function_call requests are executed and returned via conversation.item.create + response.create (TACTool.to_realtime_format()). - Capture a turn-by-turn text transcript from input/output transcription events and stash it on the ConversationSession's metadata for on_conversation_ended to persist. - Fix barge-in: response.done no longer resets truncate/clear tracking (it only means the model finished *generating*, not that Twilio finished *playing*), a response_active flag ensures we only send conversation.item.truncate while a reply is still actively streaming (avoids OpenAI's "audio content already shorter" error), and stale audio deltas for an already-truncated item are dropped instead of re-forwarded to Twilio. - Add workflow logging (connect, barge-in, tool calls) for observability. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…mpting - Default voice: ash -> marin (OpenAI's recommended voice for audio quality). - Turn detection: server_vad -> semantic_vad with eagerness=low, so the model waits for the caller to actually finish a thought instead of a fixed silence timeout, reducing awkward mid-sentence interruptions. - Restructure default instructions into OpenAI's recommended Personality/ Tone/Pacing/Variety template, including an explicit anti-repetition rule to reduce robotic-sounding responses. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds call/connect/response-latency/tool-call/barge-in spans to RealtimeVoiceChannel, nested under one root span per call so a full trace shows up per phone call instead of scattered unrelated spans. Exports to Langfuse via OTLP when LANGFUSE_PUBLIC_KEY/SECRET_KEY are set, otherwise falls back to printing spans to stdout. Also includes a local docker-compose Langfuse stack for viewing traces during development. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…s-cascaded comparison Mirrors RealtimeVoiceChannel's tracing on the ConversationRelay path so both voice architectures export the same span shape to Langfuse and can be compared apples-to-apples on latency. - VoiceChannel now emits per-call spans: conversation_relay.call (root), conversation_relay.response_latency (final transcript -> first token sent), and conversation_relay.barge_in — keyed by conversation_id so concurrent calls never share state. - Add VoiceChannel.trace_context(): public accessor letting app code nest its own spans (e.g. a model_invocation span in an on_message_ready callback) under the same call trace. In the cascaded path the app invokes the LLM, not TAC, so timing the model call is the app's job. - New example getting_started/examples/features/voice_cascaded.py — the purpose-built comparison counterpart to voice_realtime.py (same model tier, greeting, tone, tool; no memory; relay-only mode). Emits a model_invocation span with time_to_first_token_ms. - Rename default realtime TwiML path /twiml-realtime -> /twiml. - Docs: observability-demo README now covers both voice paths side by side; .env.example broadened to both channels. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary
TAC now supports two voice architectures, and this PR adds the second one
plus the observability to compare them side by side:
speech-to-speech model (OpenAI Realtime) over Twilio Media Streams. The model
does its own transcription, reasoning, synthesis, and turn detection.
TAC hands each transcribed turn to any LLM (OpenAI, Bedrock, LangChain, …) for
a text reply that ConversationRelay speaks back.
The core difference: does the audio ever become text? Cascaded turns speech
into text, runs an LLM, and synthesizes speech back (three models chained).
Realtime keeps everything as audio in a single model. Both channels export the
same OpenTelemetry span shape to Langfuse, so the two can be measured
apples-to-apples.
See
REALTIME_VOICE.mdfor the full write-up.Realtime — speech-to-speech (
RealtimeVoiceChannel)TAC is the bridge between Twilio telephony and OpenAI's speech-to-speech model.
Raw u-law audio flows both ways; TAC does no STT/TTS of its own.
flowchart LR caller([📞 Caller]) subgraph twilio[Twilio] pstn[Programmable Voice<br/>PSTN / SIP] stream[Media Streams<br/><Connect><Stream>] end subgraph app[Your app · TAC] server[RealtimeVoiceServer<br/>FastAPI] channel[RealtimeVoiceChannel<br/>audio bridge] server --- channel end subgraph openai[OpenAI] realtime[Realtime API<br/>gpt-realtime<br/>STT · reason · TTS · VAD] end caller <-->|voice call| pstn pstn --> stream stream -->|HTTP: fetch TwiML| server stream <-->|WebSocket: u-law audio| channel channel <-->|WebSocket: u-law audio| realtimeHow it works
opens a Media Streams WebSocket carrying raw call audio.
RealtimeVoiceChannelaccepts the Media Stream and relays audio to/from theOpenAI Realtime API — no text in the loop.
streams audio back.
input_audio_buffer.speech_startedTACtruncates the in-flight reply at the exact millisecond already played and
clears Twilio's playback buffer.
Trade-offs: lowest latency, most natural (preserves tone/emotion/pacing),
real barge-in — but locked to a speech-to-speech model and the text is only a
by-product (harder to inspect/guardrail mid-turn).
Call flow
sequenceDiagram participant Caller participant Twilio as Twilio Media Stream participant Channel as RealtimeVoiceChannel participant OpenAI as OpenAI Realtime Note over Caller,OpenAI: Call connects Caller->>Twilio: dials number Twilio->>Channel: POST /twiml Channel-->>Twilio: <Connect><Stream url="wss://…/voice-realtime"> Twilio->>Channel: WS open + "start" (call metadata) Channel->>OpenAI: WS open + session.update (u-law audio, server VAD) Channel->>OpenAI: response.create (speak welcome greeting) OpenAI-->>Channel: response.output_audio.delta (greeting audio) Channel-->>Twilio: media (greeting audio) Twilio-->>Caller: 🔊 "Hello! How can I help you today?" Note over Caller,OpenAI: Caller speaks Caller->>Twilio: 🎤 audio Twilio->>Channel: media (base64 u-law) Channel->>OpenAI: input_audio_buffer.append OpenAI-->>Channel: response.output_audio.delta (reply audio) Channel-->>Twilio: media (reply audio) Twilio-->>Caller: 🔊 reply Note over Caller,OpenAI: Barge-in (caller interrupts) Caller->>Twilio: 🎤 starts talking over the reply Twilio->>Channel: media Channel->>OpenAI: input_audio_buffer.append OpenAI-->>Channel: input_audio_buffer.speech_started Channel->>OpenAI: conversation.item.truncate (cut reply at played point) Channel-->>Twilio: clear (drop buffered audio)Cascaded — ConversationRelay (
VoiceChannel)Twilio's ConversationRelay does ASR and TTS; TAC hands the transcribed turn to
any LLM and streams the text reply back for Twilio to speak. The LLM is
invoked in the app's
on_message_readycallback, not inside TAC.flowchart LR caller([📞 Caller]) subgraph twilio[Twilio] pstn[Programmable Voice<br/>PSTN / SIP] relay[ConversationRelay<br/>ASR · TTS · VAD] end subgraph app[Your app · TAC] server[TACFastAPIServer<br/>FastAPI] channel[VoiceChannel] callback[on_message_ready<br/>your LLM / agent] server --- channel channel --- callback end subgraph llm[Your LLM] model[OpenAI · Bedrock ·<br/>LangChain · …<br/>text in / text out] end caller <-->|voice call| pstn pstn --> relay relay -->|HTTP: fetch TwiML| server relay <-->|WebSocket: text tokens| channel callback <-->|text prompt / streamed reply| modelHow it works
carrying text, and synthesizes the text reply back to the caller.
VoiceChannelmanages the conversation lifecycle and hands each transcribedturn to the app's
on_message_readycallback.models freely; TAC never touches the model call.
Trade-offs: you see the text every turn, so you can log, moderate, RAG,
route tools deterministically, guardrail, and swap models — but latency stacks
(ASR + LLM + TTS) and paralinguistics are lost at the text boundary.
Comparing the two in Langfuse
Both channels export the same span shape (via
src/tac/core/tracing.py), sothe architectures can be measured head-to-head.
response_latencyis the directcomparison point — "caller finishes talking → assistant's first audio/token back":
Two spans differ by design, because of where the model lives:
model_invocation(*) is emitted by the example, not by TAC. In thecascaded path the app invokes the LLM inside
on_message_ready, so TAC can'ttime it —
voice_cascaded.pywraps the call itself using the newVoiceChannel.trace_context(conv_id)to nest under the call trace. It's thecascaded stand-in for
openai_realtime.connectand breaks out the model'sshare of
response_latency.tool_callspan for the same reason: tool callsrun inside the callback (the LLM framework's own agent loop), not inside TAC.
getting_started/examples/features/voice_cascaded.pyis a fair counterpart tovoice_realtime.py: same model tier, greeting, tone, and tool; no memory oneither side; relay-only mode — so the only variable between traces is the voice
architecture itself. See
getting_started/examples/observability-demo/README.md.What's included
Realtime (new channel):
RealtimeVoiceChannel(src/tac/channels/realtime/) — the audio bridge, withbarge-in via
conversation.item.truncate+ Twilioclear.RealtimeVoiceChannelConfig— model / voice / instructions / welcome greeting.generate_stream_twiml—<Connect><Stream>TwiML.RealtimeVoiceServer(src/tac/server/) — dedicated FastAPI host (separatefrom
TACFastAPIServer), exposingPOST /twimland a/voice-realtimeWebSocket.realtimeextra (websockets).Cascaded observability + comparison:
VoiceChannel(conversation_relay.call/.response_latency/.barge_in), mirroring the realtime channel.VoiceChannel.trace_context()— public helper for nesting app-emitted spans(e.g. a model-invocation span) under the call trace.
voice_cascaded.pyexample + updated observability-demo docs.Notable implementation details
OpenAI-Betaheader; nestedaudio.input/audio.output;response.output_audio.delta). The beta shapefails against
gpt-realtimewith close code 4000.audio/pcmu(u-law) to match Twilio telephony — no transcoding.response.doneso truncation offsets stay validacross turns.
conversation_idso concurrent calls never sharestate; a console exporter is the no-credentials fallback.
Out of scope (this POC)
is not yet). Fine for testing; revisit before production.
Type of Change
Checklist
SDK Parity
This is the Python SDK.
🤖 Generated with Claude Code