DEV Community

Cover image for Why Your Voice Agent Won't Stop Talking: Building the Zero-Latency Interruption Layer with Gemma 4 E2B
Pratay Karali
Pratay Karali

Posted on

Why Your Voice Agent Won't Stop Talking: Building the Zero-Latency Interruption Layer with Gemma 4 E2B

Gemma 4 Challenge: Write about Gemma 4 Submission

The most human problem in AI — and the architecture that finally solves it.


You know the moment.

You're talking to a voice assistant. It starts giving you an answer you didn't need. You try to cut it off. You say "wait—" or "no, actually—" and it just keeps going. Plowing straight through your sentence. Talking over you like an oblivious colleague who hasn't noticed everyone else went quiet.

You eventually fall silent. You wait for it to finish. You try again.

This isn't a minor annoyance. It's the conversational uncanny valley — and it's the reason most voice AI products feel broken even when the underlying model is genuinely smart. The model might generate perfect answers. But the architecture doesn't know when to stop.

Gemma 4 E2B changes the foundational conditions of this problem. And in this guide, we're going to build the interruption layer that finally eliminates it.


The Enemy: Sequential Latency

Before we fix anything, we need to understand exactly what creates the problem.

Traditional voice pipelines look like this:

Microphone → [STT Model] → text → [LLM] → text → [TTS Model] → Speaker
Enter fullscreen mode Exit fullscreen mode

Each arrow is a waiting period. The Speech-to-Text model needs to buffer enough audio to transcribe. The LLM needs the full transcription before it can begin inference. The TTS needs the full LLM response before synthesis begins. You're not having a conversation — you're exchanging documents with an extremely fast filing system.

Compounding this: the entire pipeline is typically synchronous. While the TTS is speaking, nothing is listening. The microphone input buffer fills up. Your interruption gets queued somewhere behind three seconds of audio the system has already committed to playing.

By the time the agent "hears" you said "wait" — it's been saying something else for two full seconds. The uncanny valley isn't a UI problem. It's an architecture problem.


The Weapon: Gemma 4 E2B's Native Audio Encoder

Released by Google DeepMind on April 2, 2026, under Apache 2.0, Gemma 4 E2B is a 2.3 billion effective parameter model with something no model in its weight class has ever had: a native 300-million parameter audio encoder baked directly into the architecture.

This single design decision eliminates the first bottleneck entirely.

Instead of:

Raw audio → STT model → text string → LLM
Enter fullscreen mode Exit fullscreen mode

Gemma 4 E2B does:

Raw 16kHz audio waveform → mel-spectrogram → embedding space → LLM
Enter fullscreen mode Exit fullscreen mode

No intermediate transcription. No text flattening that destroys vocal intonation, prosody, and emotional inflection. The model hears how you said something, not just what you said.

The feature extractor processes audio using a 20ms frame length (320 samples at 16kHz) with a 10ms hop length, converting the waveform directly into mel-frequency spectrograms that project straight into the model's embedding space. The model accepts up to 30 seconds of audio per interaction — easily enough for complex, natural queries.

Here's what makes the E2B variant special in the Gemma 4 family:

Variant Effective Params Context Target Hardware Audio?
E2B 2.3B 128K Phones, RPi 5, Laptops ✅ Yes
E4B 4.5B 128K High-end phones, edge servers ✅ Yes
26B A4B ~4B active (MoE) 256K RTX 4090/5090 ❌ No
31B 30.7B 256K A100/H100 ❌ No

The native audio encoder exists only in the E2B and E4B variants. If you're building a real-time voice agent, you're using E2B. Under 4-bit quantization (Q4_K_M), it runs in 2–3 GB of RAM — fitting comfortably on a Raspberry Pi 5 or any modern laptop.

The architecture achieves this through three innovations worth understanding:

Per-Layer Embeddings (PLE): Instead of one embedding matrix at the input, every decoder block gets its own specialized embedding slice. These function as memory lookups rather than matrix multiplications — so the model accesses 5.1 billion parameters worth of knowledge while only activating 2.3 billion per token. Fast inference, deep intelligence.

Hybrid Attention (4:1 Local/Global): Rather than full global attention across 128K tokens (which scales quadratically — catastrophic on edge hardware), E2B applies local sliding-window attention (512 tokens) for four consecutive layers, then one full global attention layer. 35 layers total, always ending global. The RULER benchmark puts long-context recall at 66.4% at 128K depth — versus 13.5% in prior generations.

p-RoPE: Proportional Rotary Position Embeddings dedicate a subset of dimensions strictly to positional data, leaving 75% as clean content channels. This prevents the catastrophic forgetting that typically afflicts long voice conversations.


The Gatekeeper: Silero VAD

Eliminating STT latency is half the battle. The other half is knowing when to listen.

You cannot feed a raw, open microphone stream into an LLM. It will constantly trigger on ambient noise, keyboard clicks, HVAC hum, your dog. Every false trigger is a wasted inference cycle. On consumer hardware, that means freezing the application.

Enter Silero VAD.

One megabyte. Trained on 100+ languages. Forward pass executes in under one millisecond on a single CPU thread. It returns a speech probability scalar between 0.0 and 1.0 for every 30-32ms audio chunk (512 samples at 16kHz).

The critical detail is hysteresis — raw probability alone causes rapid toggling. You need:

  • Speech start: probability > 0.5 for at least 250ms of consecutive audio
  • Speech end: silence for at least 500ms These thresholds prevent a door slam from triggering a full inference cycle, and prevent a natural mid-sentence pause from prematurely ending the user's utterance.

The Architecture: Four Threads, One Goal

Here's the core insight: the reason voice agents fail at interruption is that they're architecturally single-threaded in spirit even when multi-threaded in implementation. The microphone, the VAD, the LLM, and the speaker all wait for each other.

Our system has four completely decoupled components communicating via thread-safe queues and a single shared threading.Event() flag:

┌─────────────────────────────────────────────────────────────┐
│                    SYSTEM ARCHITECTURE                       │
│                                                              │
│  ┌──────────────┐    ┌──────────────┐    ┌──────────────┐  │
│  │  PyAudio     │    │  Silero VAD  │    │  Gemma 4 E2B │  │
│  │  C-Thread    │───▶│  Gatekeeper  │───▶│  Inference   │  │
│  │  (non-block) │    │  (CPU only)  │    │  Engine      │  │
│  └──────────────┘    └──────┬───────┘    └──────┬───────┘  │
│                             │                   │           │
│                    interrupt_event.set()    response text   │
│                             │                   │           │
│                             ▼                   ▼           │
│                    ┌──────────────────────────────────┐     │
│                    │     TTS Output Worker Thread     │     │
│                    │  polls interrupt_event every     │     │
│                    │  50ms chunk → instant flush      │     │
│                    └──────────────────────────────────┘     │
└─────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The interrupt_event is the entire system's nervous system. The moment Silero detects speech onset, it fires. The TTS worker is polling that flag on every 50ms audio chunk write. The instant it fires — mid-syllable if necessary — the TTS queue is flushed. The agent goes silent. The user has the floor.


Building It: Phase by Phase

Environment Setup

pip install -U transformers torch accelerate onnxruntime
pip install pyaudio soundfile silero-vad bitsandbytes
Enter fullscreen mode Exit fullscreen mode

onnxruntime ensures Silero runs on the ONNX Lite backend for maximum CPU efficiency. bitsandbytes handles 4-bit quantization to compress the model to ~3.5GB VRAM.


Phase 1: The VAD Gatekeeper (Non-Blocking Audio Capture)

Python's GIL is the enemy of real-time audio. The moment you write stream.read() in a while True loop, you've blocked your entire thread during LLM inference. Microphone buffer overflows. Interruptions get lost.

The solution: PyAudio's non-blocking stream_callback. This delegates audio capture to a dedicated C-level PortAudio thread — completely outside the Python GIL.

import torch
import numpy as np
import pyaudio
import queue
import threading

class VADGatekeeper:
    def __init__(self, sample_rate=16000, chunk_size=512):
        self.sample_rate = sample_rate
        self.chunk_size = chunk_size

        # Silero VAD via ONNX backend — ~1MB, <1ms per forward pass
        self.model, utils = torch.hub.load(
            repo_or_dir='snakers4/silero-vad',
            model='silero_vad',
            force_reload=False,
            onnx=True
        )
        (self.get_speech_timestamps, _, _, self.VADIterator, _) = utils

        # Stateful iterator: 0.5 threshold, 500ms silence to confirm end
        self.vad_iterator = self.VADIterator(
            self.model,
            threshold=0.5,
            min_silence_duration_ms=500
        )

        self.audio_queue = queue.Queue()
        self.is_speaking = False
        self.interrupt_event = threading.Event()
        self.current_utterance_frames = []

    def audio_callback(self, in_data, frame_count, time_info, status):
        """
        Executed in a C-level PortAudio thread.
        Deposits audio bytes into queue and returns INSTANTLY.
        """
        self.audio_queue.put(in_data)
        return (None, pyaudio.paContinue)
Enter fullscreen mode Exit fullscreen mode

The audio_callback does exactly one thing: put bytes in the queue and return. The callback executes every 32ms. It never blocks. It never waits for LLM inference to finish. The microphone is always listening.


Phase 2: The Gemma 4 E2B Inference Engine

Loading E2B in native 16-bit precision requires 10+ GB VRAM. On consumer hardware, we use BitsAndBytesConfig for 4-bit NF4 quantization — collapsing that to ~3.5 GB:

from transformers import AutoProcessor, AutoModelForMultimodalLM, BitsAndBytesConfig

class GemmaVoiceEngine:
    def __init__(self, model_id="google/gemma-4-E2B-it"):
        self.device = "cuda" if torch.cuda.is_available() else "cpu"

        bnb_config = BitsAndBytesConfig(
            load_in_4bit=True,
            bnb_4bit_use_double_quant=True,
            bnb_4bit_quant_type="nf4",
            bnb_4bit_compute_dtype=torch.bfloat16
        )

        self.processor = AutoProcessor.from_pretrained(model_id)
        self.model = AutoModelForMultimodalLM.from_pretrained(
            model_id,
            quantization_config=bnb_config,
            device_map="auto"
        )
        self.conversation_history = []

    def generate_response(self, audio_numpy_array):
        # CRITICAL: audio content block MUST precede text block
        user_message = {
            "role": "user",
            "content": [
                {"type": "audio", "audio": audio_numpy_array},
                {"type": "text", "text": "Please respond to the audio input concisely."}
            ]
        }
        self.conversation_history.append(user_message)

        inputs = self.processor.apply_chat_template(
            self.conversation_history,
            tokenize=True,
            return_dict=True,
            return_tensors="pt",
            add_generation_prompt=True
        ).to(self.device)

        input_len = inputs["input_ids"].shape[-1]
        outputs = self.model.generate(**inputs, max_new_tokens=256)

        response_text = self.processor.decode(
            outputs[input_len:],
            skip_special_tokens=True
        )
        self.conversation_history.append(
            {"role": "assistant", "content": response_text}
        )
        return response_text
Enter fullscreen mode Exit fullscreen mode

One non-obvious detail: the {"type": "audio"} block must physically precede the {"type": "text"} block in the content array. This is an architectural requirement of Gemma 4's multimodal formatting — the audio token expansions need to be computed before the text instructions are interpolated. Getting this wrong causes silent inference failures.

When apply_chat_template is called, the processor runs the mel-spectrogram computation, determines the exact number of audio tokens based on waveform duration, and stitches the audio representations into the prompt in place of the structural placeholder. The complexity of audio tokenization is entirely abstracted.


Phase 3: The Output Controller (The Flush Mechanism)

This is where interruption actually happens. The TTS worker polls interrupt_event on every single audio chunk before writing to the speaker:

class OutputController:
    def __init__(self):
        self.audio_out = pyaudio.PyAudio()
        self.out_stream = self.audio_out.open(
            format=pyaudio.paInt16,
            channels=1,
            rate=24000,
            output=True
        )
        self.playback_queue = queue.Queue()

    def tts_playback_worker(self, interrupt_event):
        while True:
            # Check BEFORE dequeuing
            if interrupt_event.is_set():
                print("[Output] Interruption. Flushing queue.")
                while not self.playback_queue.empty():
                    try:
                        self.playback_queue.get_nowait()
                    except queue.Empty:
                        break
                interrupt_event.clear()
                continue

            try:
                audio_chunk = self.playback_queue.get(timeout=0.05)

                # Check AGAIN immediately before hardware write
                if interrupt_event.is_set():
                    continue

                # Blocking write to physical speaker
                self.out_stream.write(audio_chunk)

            except queue.Empty:
                continue
Enter fullscreen mode Exit fullscreen mode

The double-check pattern — once before dequeuing, once before the hardware write — closes the race condition window as tightly as physically possible. The maximum interruption delay equals the duration of one audio chunk: 50 milliseconds. That's imperceptible to humans.

Feed 50ms TTS chunks into playback_queue. The agent stops mid-syllable when interrupted. This is the uncanny valley fix.


Phase 4: The Orchestration Loop

Everything unified — the moment speech starts, the interrupt fires. When silence confirms the utterance is complete, inference runs:

def main_orchestration_loop():
    gatekeeper = VADGatekeeper()
    agent = GemmaVoiceEngine()
    output_ctrl = OutputController()

    tts_thread = threading.Thread(
        target=output_ctrl.tts_playback_worker,
        args=(gatekeeper.interrupt_event,),
        daemon=True
    )
    tts_thread.start()

    p = pyaudio.PyAudio()
    mic_stream = p.open(
        format=pyaudio.paInt16,
        channels=1,
        rate=gatekeeper.sample_rate,
        input=True,
        frames_per_buffer=gatekeeper.chunk_size,
        stream_callback=gatekeeper.audio_callback  # Non-blocking C thread
    )
    mic_stream.start_stream()
    print("Listening. Speak freely — interrupt anytime.")

    try:
        while True:
            raw_chunk = gatekeeper.audio_queue.get()

            # Normalize bytes → float32 [-1.0, 1.0] for VAD
            audio_data = np.frombuffer(raw_chunk, dtype=np.int16)
            audio_float32 = audio_data.astype(np.float32) / 32768.0
            tensor_chunk = torch.from_numpy(audio_float32)

            speech_dict = gatekeeper.vad_iterator(tensor_chunk)

            if speech_dict:
                if 'start' in speech_dict:
                    print("Speech detected — firing interrupt...")
                    gatekeeper.interrupt_event.set()  # TTS stops NOW
                    gatekeeper.is_speaking = True
                    gatekeeper.current_utterance_frames = []

                elif 'end' in speech_dict:
                    print("Utterance complete. Processing...")
                    gatekeeper.is_speaking = False
                    gatekeeper.current_utterance_frames.append(audio_float32)

                    full_waveform = np.concatenate(
                        gatekeeper.current_utterance_frames
                    )
                    response = agent.generate_response(full_waveform)
                    print(f"Agent: {response}")
                    # Route response to your TTS generator here
                    gatekeeper.current_utterance_frames = []

            if gatekeeper.is_speaking:
                gatekeeper.current_utterance_frames.append(audio_float32)

    except KeyboardInterrupt:
        mic_stream.stop_stream()
        mic_stream.close()
        p.terminate()

if __name__ == "__main__":
    main_orchestration_loop()
Enter fullscreen mode Exit fullscreen mode

The timing guarantee: Silero detects speech onset within 32 milliseconds (one 512-sample chunk at 16kHz). interrupt_event.set() propagates across the memory barrier to the TTS worker in microseconds. The TTS flush completes within one chunk cycle — 50ms. Total interruption latency: under 82ms from first syllable to silence.

Human perception of conversational delay becomes noticeable around 200ms. We're well inside that window.


The Memory Constraint Nobody Mentions

There's a hard reality that documentation often glosses over: the 128K context window is theoretically available, but practically unreachable on consumer hardware.

The Gemma 4 E2B KV cache allocates approximately 490 KB per token due to its dense 256 head dimension. Filling 128K tokens would require 60+ GB of VRAM for the cache alone. On a machine with 16 GB unified memory, you can safely operate up to roughly 8,000 tokens of conversation history.

This means you need aggressive context pruning:

# Keep the system prompt + last N turns
MAX_HISTORY_TOKENS = 6000

def prune_history(self):
    # Estimate tokens and trim oldest turns
    while self.estimate_tokens() > MAX_HISTORY_TOKENS:
        # Remove oldest user/assistant turn pair
        if len(self.conversation_history) > 2:
            self.conversation_history.pop(1)
            self.conversation_history.pop(1)
Enter fullscreen mode Exit fullscreen mode

For voice agents requiring hours of continuous memory — think customer service bots or long-form interview assistants — migrate the backend from transformers to llama.cpp with GGUF format. The TCQ and q4_0 quantization algorithms in llama.cpp apply rotational matrix compression to KV vectors, preserving semantic accuracy while dramatically reducing cache memory. This is the mandatory optimization for production long-session voice deployments.


Beyond VAD: Semantic Interruption

The system above is production-ready for most use cases. But there's one failure mode to know about: backchannels.

When a user murmurs "mm-hmm" or "right" in passive agreement, Silero correctly detects acoustic energy and fires the interrupt. The agent stops speaking. The user wasn't actually interrupting — they were listening.

The fix: semantic yielding.

Instead of a destructive flush on speech onset, make it a reversible pause:

  1. Pause the TTS stream (don't flush) when VAD fires
  2. Capture the short interjection
  3. Classify via a fast sub-billion parameter model:
    • Backchannel ("mm-hmm", "right", "okay") → unpause, resume seamlessly
    • Genuine interruption ("wait", "stop", "what about—") → flush, route to E2B
  4. Classification adds ~80-100ms overhead — still within the imperceptible window This elevates the system from prototype to something that genuinely feels like a conversation.

What This Actually Unlocks

The architecture in this guide runs entirely offline. No API calls. No cloud dependency. No subscription. A Raspberry Pi 5 can host a voice agent that:

  • Hears you natively — not transcribed text, actual audio with prosody and intent
  • Responds intelligently across a multi-hour conversation context
  • Stops the moment you start speaking — every single time
  • Gets smarter about your patterns over time with persistent conversation history We've spent years accepting that voice AI is clunky because it has to be. Gemma 4 E2B makes that tradeoff optional. The uncanny valley was always an architecture problem. We just finally have the pieces to solve it on hardware that fits in your pocket.

Quick Start

# Install dependencies
pip install -U transformers torch accelerate onnxruntime
pip install pyaudio soundfile silero-vad bitsandbytes

# Pull via Ollama if you prefer the managed route
ollama pull gemma4:e2b

# Or load directly via Hugging Face
# model_id = "google/gemma-4-E2B-it"
Enter fullscreen mode Exit fullscreen mode

Start with the orchestration loop, verify your Silero VAD fires correctly on your microphone, then wire in your preferred TTS engine to output_ctrl.playback_queue. The interruption layer works regardless of which TTS you choose — Kokoro, Edge-TTS, Coqui, anything that produces audio chunks.

The agent that finally listens is one thread event away.


Written for the Gemma 4 Writing Challenge on DEV.to. Deadline: May 24, 2026.

Top comments (0)