Building the Full-Duplex Pipeline

9 min read Module 5 of 9 Topic 14 of 25

What you'll learn

  • Explain the difference between half-duplex and full-duplex conversational audio pipelines
  • Implement barge-in handling that cancels in-flight TTS playback the instant the user starts speaking
  • Describe why echo cancellation is necessary when a device's own speaker output can be picked up by its own microphone
  • Design an event loop that arbitrates between listening and speaking states without deadlocking
Building this at your company? For enterprise and company teams taking this to production: book a 30-minute session with our AI engineers for architecture guidance, code review, and a rollout plan for your use case.
Book a Team Session

Every stage covered so far, from STT through TTS through transport, has implicitly assumed a clean turn-taking structure: the user speaks, then the agent speaks, then the user speaks again. Real conversation doesn’t work that way. People interrupt, correct themselves mid-sentence, and talk over an assistant that’s said something wrong or something they already know. A pipeline that can’t handle that isn’t a lesser version of a real assistant, it’s a different, more frustrating product wearing the same UI. This lesson builds the full-duplex architecture that makes a voice agent feel like it’s actually listening, not just waiting for its turn to talk.

Half-duplex vs full-duplex

A half-duplex pipeline strictly alternates between two states: listening or speaking, never both. It’s simpler to build, and it’s the default shape you get if you wire STT, LLM, and TTS together without deliberately engineering for interruption, because the natural request-response mental model maps directly onto it. The problem is that it makes barge-in structurally impossible: if the microphone stream isn’t being processed while TTS is playing, no amount of user speech registers until the agent finishes talking, which in a long response can mean several seconds of a caller repeating “wait, no” into a system that isn’t listening.

Full-duplex means both directions are live simultaneously: the microphone is always streaming to the STT engine, even mid-TTS-playback, so voice activity from the user can be detected and acted on in real time regardless of what the agent is currently doing. This is a genuinely harder system to build correctly, because now two audio streams are active concurrently and the system needs a clear policy for what happens when they collide, but it’s the only architecture that supports natural interruption, which is why every production-grade voice agent framework treats it as a baseline requirement rather than an advanced feature.

Barge-in: canceling the agent mid-sentence

Barge-in handling is the concrete mechanism that makes full duplex useful rather than just architecturally interesting. The moment voice activity detection (VAD) on the incoming microphone stream crosses a confidence threshold indicating the user has started speaking, the system needs to immediately stop sending further TTS audio to playback, cancel any pending or in-flight synthesis requests for the current response, and transition state to actively processing the user’s new input. Every millisecond of delay here is a millisecond the agent spends visibly not listening, which is the exact failure mode that makes voice interfaces feel broken rather than conversational.

import asyncio
from enum import Enum, auto

class AgentState(Enum):
    LISTENING = auto()
    SPEAKING = auto()

class DuplexController:
    def __init__(self):
        self.state = AgentState.LISTENING
        self.playback_task: asyncio.Task | None = None
        self.tts_stream_task: asyncio.Task | None = None

    async def on_vad_speech_start(self):
        """Fires the instant VAD detects the user has started talking,
        regardless of what the agent is currently doing."""
        if self.state == AgentState.SPEAKING:
            await self.barge_in()
        self.state = AgentState.LISTENING

    async def barge_in(self):
        """Cancel in-flight synthesis and stop playback immediately."""
        if self.playback_task and not self.playback_task.done():
            self.playback_task.cancel()
        if self.tts_stream_task and not self.tts_stream_task.done():
            self.tts_stream_task.cancel()
        await self.playback.flush()  # drop any buffered audio, don't let it drain out

    async def on_agent_response_ready(self, sentence_stream):
        """Start speaking, but stay interruptible the whole time."""
        self.state = AgentState.SPEAKING
        self.tts_stream_task = asyncio.create_task(self._synthesize(sentence_stream))
        self.playback_task = asyncio.create_task(self._play())
        await asyncio.gather(self.tts_stream_task, self.playback_task, return_exceptions=True)
        if self.state == AgentState.SPEAKING:
            self.state = AgentState.LISTENING

The controller here treats SPEAKING and LISTENING as states that can be interrupted at any point rather than steps in a fixed sequence, and every task doing work on behalf of the current response is cancellable, not fire-and-forget. That cancellability is the entire architectural point: a half-duplex system has no equivalent of barge_in() because it never has an in-flight response to cancel while also listening.

Echo cancellation: not hearing yourself

Full duplex introduces a problem half-duplex never has to face: if the microphone is live while the agent is speaking, and the agent’s audio is coming out of a nearby speaker, the microphone will pick up the agent’s own voice. Without correction, that creates a nasty feedback scenario where the STT engine transcribes the agent’s own TTS output as if it were user speech, and the agent ends up responding to itself, sometimes triggering runaway conversational loops that are confusing to debug and embarrassing to demo.

Acoustic echo cancellation (AEC) solves this using a well-established signal processing technique: since the system knows exactly what audio it’s currently sending to the speaker (it’s the reference signal, generated by its own TTS pipeline), it can subtract a modeled version of that signal from what the microphone picks up, canceling out the echo while preserving genuine user speech. Browsers implement AEC natively as part of the WebRTC media stack (echoCancellation: true on getUserMedia constraints), and telephony and dedicated hardware setups typically rely on the device or carrier’s own AEC implementation. This is one of the clearest cases in this course where using WebRTC (Module 5’s earlier lesson) rather than raw audio sockets pays for itself immediately: AEC is exactly the kind of purpose-built real-time media handling that’s expensive to reimplement and comes free with a proper WebRTC stack.

Putting it together: the event loop

A production full-duplex event loop needs to run VAD continuously on the input stream, maintain the agent’s current state (listening or speaking), and route every event, whether it’s a completed user utterance, a new LLM token, or a detected barge-in, through a single arbitration point rather than scattering state transitions across independent handlers that can race each other. The pattern worth internalizing is that “the agent is speaking” is never an excuse to stop processing input; it’s just one of two states the input processor always has to account for, and the moment those two paths (input processing, output playback) stop being coupled through a shared, cancellable state machine is the moment race conditions start showing up as an agent that occasionally talks over itself or gets stuck mid-response with no way to recover.

Knowledge Check

3 questions to test your understanding

1 A voice agent finishes speaking a long answer before it starts listening for the next user turn, strictly alternating between 'agent speaks' and 'agent listens' states. A user tries to interrupt mid-answer to correct a misunderstanding, but the agent talks over them and finishes its sentence anyway. What architectural change fixes this?

2 A full-duplex voice agent on a phone speaker occasionally transcribes its own TTS output as if the user had said it, causing the agent to respond to itself. What's the most likely missing component?

3 In a barge-in-aware event loop, the moment the system detects the user has started speaking mid-response, what should happen to the in-flight TTS audio, and why does timing matter?

Discussion

Questions and notes from learners on this topic

Loading discussion…

Go further with expert guidance

Ready to build production AI?
Talk to our R&D team.

These courses give you the foundation. Our embedded AI teams take you from prototype to production in 30–90 days, with your team, your codebase, your goals. Book a free strategy call to see how we can accelerate your AI initiative.

30 minutes · No obligation · Expert AI engineers, not sales reps

AI Architecture Review

Audit your current stack and identify high-impact improvements

Project Review

Get expert feedback on your AI implementation and codebase

Team Mentoring

Upskill your engineers with hands-on AI coaching sessions

AI Strategy

Define your AI roadmap, prioritization, and implementation plan