The goal

This first log for Ahmad Voice AI covers real-time speech transport architecture. The objective was straightforward: build a terminal-based voice assistant that listens to hardware microphone input, streams raw PCM audio over WebSockets to a real-time model, receives audio back, and supports continuous multi-turn conversations without dropping connection state.

The journey ran in two phases. Phase 1 was a full attempt on Gemini Live API — Python's asyncio event loop, PyAudio bindings to PortAudio, the google-genai SDK, and bidirectional WebSockets over TLS. Phase 2 was a migration to Deepgram's Voice Agent API after the Gemini transport proved unfixable at the client layer.

Six distinct Gemini failures were diagnosed and fixed. The seventh issue was not a bug — it was the wrong provider for the job.

Phase 1: Gemini Live API

The system relied on three parallel concurrent loops running inside asyncio:

Turn 1 always succeeded instantly. As soon as the model finished speaking and the user attempted Turn 2, the connection terminated with error 1011 keepalive ping timeout. Resolving this required tackling six distinct issues.

The 1011 was never really a WebSocket-protocol problem. It was an application-layer signal: the client stopped participating in the session for several seconds, and the server's keepalive timer was a proxy for detecting that. Every fix below is ultimately about keeping the application layer active during model speech.

Failure modes & debugging log

Bug 01 · Asyncio Concurrency

Blocking C-Extension Calls Freezing the Event Loop

PyAudio's audio_stream.read() and speaker_stream.write() are synchronous C-extension calls. Running them directly inside an asyncio task froze Python's single thread while waiting for physical soundcard buffer fills or drains. Because the event loop deadlocked during these calls, background WebSocket ping/pong control frames were dropped, prompting the server to drop the transport.

The fix was wrapping both hardware read and write calls inside thread executors:

data = await loop.run_in_executor(None, audio_stream.read, CHUNK_SIZE, False)

# Speaker side — same problem, same fix
await loop.run_in_executor(None, speaker_stream.write, part.inline_data.data)

Even with the executor, the receive loop could still starve between chunks. A yield before each write gave the WebSocket reader a chance to process control frames:

await asyncio.sleep(0)
await loop.run_in_executor(None, speaker_stream.write, part.inline_data.data)

Lesson: never execute blocking hardware C-extension calls directly on an asyncio event loop — on either the input or the output side.

Bug 02 · Memory Buffering

Queue Flooding and Socket Saturation

While the model was speaking for several seconds, PyAudio accumulated raw PCM audio chunks in C memory buffers. When the model finished, the loop drained these buffered frames instantly, flooding the WebSocket with dozens of stale audio frames in milliseconds, exceeding write buffer thresholds.

The fix was decoupling capture from transmission using an asyncio.Queue() and actively flushing hardware input buffers while the model was speaking:

if is_model_speaking:
    audio_stream.read(audio_stream.get_read_available(), False)
    await asyncio.sleep(0.05)

Lesson: clear hardware audio capture buffers during output playback to prevent queue flooding.

Bug 03 · Transport Keepalives

Outbound Transport Silence During Playback

To prevent feedback loops, the client ceased sending microphone frames while the model was speaking. However, outbound silence across even short playback windows violated transport activity requirements, causing the server gateway to assume client disconnection and issue error 1011. In practice the drops fired after only 2–3 seconds of send-side quiet, not the multi-second threshold initially assumed.

An intermediate fix sent an explicit audio_stream_end marker followed by zero-padded PCM silence frames (b"\x00" * 3200) every 100ms during playback to keep the transport active. But sending silence after the end marker confused the server-side Voice Activity Detection — the model would sit idle waiting for an utterance that never properly terminated.

The correct pattern was to send audio_stream_end once, then stop sending audio entirely and simply await the model's turn:

if is_model_speaking:
    # Flush stale queue
    while not audio_queue.empty():
        audio_queue.get_nowait()

    if not stream_end_sent:
        await session.send_realtime_input(audio_stream_end=True)
        stream_end_sent = True

    # Do NOT send silence. Sleep and yield to the receive loop.
    await asyncio.sleep(0.02)
    continue

Lesson: signal the end of the audio stream explicitly; do not paper over the pause with silence frames, which the VAD will misinterpret.

Bug 04 · API Compatibility

Session Resumption Is Vertex-Only — No Built-In Reconnect

Configuring session_resumption=types.SessionResumptionConfig(transparent=True) caused immediate connection rejection: Session Error: transparent parameter is only supported in Gemini Enterprise Agent Platform mode, not in Gemini Developer API mode.

This is not a bug in the SDK — the parameter is legitimate, but it is gated to Vertex AI / Gemini Enterprise. On the public Developer API (the mode used when authenticating with a GOOGLE_API_KEY), there is no built-in session resumption at all.

The real consequence: preview models will still drop connections intermittently, and the client has no primitive to resume a dropped session. Reconnection has to be implemented at the application level. The parameter was removed from LiveConnectConfig so the session could initialize cleanly on a public API key.

Lesson: session resumption is unavailable on the Developer API — plan for application-level reconnection from the start.

Bug 05 · Session Lifecycle

Reconnection Must Happen at the main() Level

With no built-in resumption, the client still dropped periodically. The natural impulse was to retry inside ws_sender_task, but the session object returned by client.aio.live.connect() is bound to the dead WebSocket. It cannot be reused. Retrying inside any of the three worker tasks would either leak a dead session or block the surviving tasks.

The fix was an outer while True loop wrapping the entire async with client.aio.live.connect(...) block, with exponential backoff. Critically, the inner wait uses asyncio.wait(..., return_when=FIRST_EXCEPTION) instead of asyncio.gather(), so the client can tell which task died:

done, pending = await asyncio.wait(
    [mic_task, send_task, output_task],
    return_when=asyncio.FIRST_EXCEPTION
)

for task in pending:
    task.cancel()
await asyncio.gather(*pending, return_exceptions=True)

# Mic death = fatal. WS death = retryable.
for task in done:
    if task is mic_task:
        raise task.exception() or Exception("Mic died")

Backoff was exponential with a 30-second cap, and reset to 1s on each successful connection to avoid reconnect storms. A session resumption handle was stored and passed back on reconnect so the conversation context survived the drop even without Vertex-grade resumption.

Lesson: distinguish fatal hardware failures from retryable transport failures, and own the reconnect loop at the top of the program.

Bug 06 · Model Behavior

Internal Reasoning Emitted as Text Instead of Audio

On gemini-2.5-flash-native-audio-preview-12-2025, the model intermittently emitted chain-of-thought text in place of audio — strings like "Formulating Polite Reply" or "Seeking Clarification on Issue" — and then produced no audio at all. The transport sat idle, and the same 1011 followed.

This is a model-side bug, not a transport bug. No client-side change resolves it. Switching back to gemini-3.1-flash-live-preview removed the reasoning-text leak, but did not eliminate the periodic connection drops. The transport layer was now correct, and the model was still the remaining variable.

Lesson: when a preview model leaks internal reasoning as text and produces no audio, the correct response is to change models — not to chase the resulting transport symptom.

Phase 2: The migration to Deepgram

With all six transport bugs fixed, the Gemini client was stable when the connection held — but the connection still dropped periodically, and the Developer API provides no session resumption. Every drop forced a fresh session, and multi-turn context was lost. Patching around this indefinitely was the wrong direction.

The decision was to migrate to Deepgram's Voice Agent API, which packages speech recognition, LLM orchestration, and neural speech synthesis into a single managed WebSocket session. The provider owns the transport, the keepalive, the turn-boundary signalling, and the reconnection model. The application code stops being a WebSocket engineer and goes back to being a voice agent developer.

What the migration removed

  • Manual reconnect supervisor. The outer while True loop, exponential backoff, and asyncio.wait(FIRST_EXCEPTION) pattern were deleted. The SDK owns the session lifecycle.
  • audio_stream_end bookkeeping. Turn-boundary signals are handled server-side by the Voice Agent API. No stream_end_sent flag, no VAD confusion.
  • Keepalive engineering. No silence frames, no ping/pong tuning, no application-layer participation timers to satisfy. The 1011 class of failure is architecturally absent.
  • Manual asyncio audio pipeline. The three-task architecture (mic, sender, speaker) collapsed into a single threaded sender feeding a managed connection that emits events.

Final architecture

The shipped client runs a single Deepgram WebSocket session configured as a JSON Settings message on connect. That message declares the full agent pipeline in one payload:

Microphone frames are read on a background thread and pushed with connection.send_media(). Agent audio arrives as binary WebSocket frames and is written directly to the speaker. Text events (ConversationText, UserStartedSpeaking, AgentThinking, AgentStartedSpeaking, AgentAudioDone, SettingsApplied) are logged to the terminal for observability.

Deepgram-specific issues resolved

Bug 07 · Message Parsing

UNPARSABLE_CLIENT_MESSAGE on the Greeting Field

The first Settings message was rejected with Error parsing client message. Check the greeting field against the API spec. Two mistakes stacked here. First, the greeting field was placed at the top level of the Settings payload rather than inside the agent object. Second, the typed SDK classes in deepgram-sdk 7.10.0 produce a payload the server rejects when the field shape is wrong.

The fix was to place greeting inside AgentV1SettingsAgent and send via the typed send_settings() method, which the SDK expects:

agent=AgentV1SettingsAgent(
    language="en",
    listen=...,
    think=...,
    speak=...,
    greeting="Hello! How can I help you today?",
)

Lesson: the Deepgram server uses deny_unknown_fields. Any field placed at the wrong nesting level produces UNPARSABLE_CLIENT_MESSAGE — read the rejection as a schema error, not a network error.

Bug 08 · SDK Surface

No Generic send() on V1SocketClient

An attempt to bypass the typed classes by sending raw JSON via connection.send(json.dumps(settings)) failed with AttributeError: 'V1SocketClient' object has no attribute 'send'. Did you mean: '_send'?

The Deepgram Python SDK exposes only typed methods on the socket client — send_settings(), send_media(), send_keep_alive(), and so on. There is no generic send. The fix was to revert to the typed path and correct the field nesting, which resolved the original parse error as well.

Lesson: the Deepgram SDK is typed by design. Work with the provided methods rather than trying to hand-roll the payload.

Where it stands now

The voice client ships as Ahmad Voice AI on the Deepgram Voice Agent API. It handles continuous multi-turn conversation over a single managed WebSocket session, with no manual keepalive, no reconnect loop, and no transport-level engineering left in the application code. The free tier on Deepgram covers roughly 40+ hours of this workload, which was the deciding factor over Gemini's limited preview quota.

<500ms
Voice-to-Voice Latency
16 kHz
Input PCM Sample Rate
24 kHz
Output PCM Sample Rate
0
Client-Side Keepalive Timers

Engineering Summary

Real-time speech pipelines require treating transport networks and hardware I/O as distinct systems. Delegating blocking calls to thread pools on both the input and output sides, flushing stale hardware buffers during speaker output, and owning the reconnect loop at the top of the program are all correct patterns — and all of them were necessary to get a stable multi-turn session out of Gemini Live.

But the final lesson is a different one. When the transport layer is correct and the connection still drops, the right move is not to keep patching the client. It is to ask whether the provider's architecture is the right fit for the workload. Deepgram's Voice Agent API absorbs the entire class of failure that took six bugs to partially mitigate, because the provider treats transport reliability as its problem, not the application's.

The deeper takeaway: 1011 keepalive ping timeout is almost never about the WebSocket ping mechanism itself. It is the server's proxy signal for "the application stopped participating." Fixing the application layer fixes the transport — and choosing the right provider removes the need to fix it at all.


Want to review the search architecture? Read Log 02 →