Fix echo-induced silence with a half-duplex audio gate

A caller's reply was generated but never heard: 0.65s after the agent started
speaking, the VAD fired "user started speaking" (NO transcript) and broadcast an
interruption that cancelled the agent's audio -> ~24s of silence until the caller
spoke again. Cause: the agent's own TTS echoes back the phone line and the
always-on VAD interruption treats it as a barge-in. (PipelineParams has no
allow_interruptions in this pipecat build — it was a silent no-op.)

Fix: HalfDuplexGate before the VAD withholds inbound audio while the bot speaks
(+ECHO_TAIL_SECS, default 0.5s), so echo can't trigger a false barge-in.
Half-duplex (no mid-utterance barge-in); HALF_DUPLEX=false to restore it.
Runtime-tested the gate (pass idle / drop while speaking / drop in tail / resume).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tocmo0nlord
2026-06-27 16:44:00 +00:00
parent ceea3d151c
commit 32a3bb7136
3 changed files with 50 additions and 0 deletions

View File

@@ -59,3 +59,7 @@ AGENT_NAME=AVA
AGENT_NAME_SPOKEN=Eva
# Grace pause after the goodbye before the carrier leg is dropped (seconds).
HANGUP_DELAY_SECS=4.0
# Half-duplex: ignore caller audio while the agent speaks (+ tail) so its own echo on the
# phone line can't trigger a false barge-in that cancels its reply. false = allow barge-in.
HALF_DUPLEX=true
ECHO_TAIL_SECS=0.5

View File

@@ -62,6 +62,15 @@ VAD side.
**`AudioHeartbeat`** — diagnostic processor that distinguishes VAD failure from
transport stall. Keep it.
**`HalfDuplexGate` in `bot.py`** — fixes echo-induced mid-call silence. In this pipecat build
interruptions are VAD-driven and always on (`PipelineParams.allow_interruptions` does NOT exist
— it's silently ignored). On a phone line the agent's own TTS echoes back, the VAD reads it as
the caller speaking (it produces NO transcript), and the broadcast interruption cancels the
agent mid-reply → the caller hears silence. This gate sits BEFORE the VAD and withholds inbound
audio while the bot is speaking (+`ECHO_TAIL_SECS`, default 0.5s) so echo never reaches the VAD.
Trade-off: half-duplex — the caller can't barge in mid-utterance (fine for short replies).
`HALF_DUPLEX=false` restores barge-in. Keep it on for telephony.
**Post-call extraction (`extract.py`)** — single JSON-mode completion after call ends.
Correctly uses `format: json`, uses verified Twilio caller-ID instead of trusting model
output, falls back to JSONL if Odoo is unreachable. Keep it.

37
bot.py
View File

@@ -23,6 +23,7 @@ from pipecat.adapters.schemas.tools_schema import ToolsSchema
from pipecat.audio.vad.silero import SileroVADAnalyzer
from pipecat.audio.vad.vad_analyzer import VADParams
from pipecat.frames.frames import (
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
EndFrame,
EndTaskFrame,
@@ -101,6 +102,11 @@ VAD_CONFIDENCE = float(os.environ.get("VAD_CONFIDENCE", "0.5"))
VAD_MIN_VOLUME = float(os.environ.get("VAD_MIN_VOLUME", "0.3"))
VAD_START_SECS = float(os.environ.get("VAD_START_SECS", "0.2"))
VAD_STOP_SECS = float(os.environ.get("VAD_STOP_SECS", "0.5"))
# Half-duplex: ignore inbound audio while the agent is speaking (+ this tail in seconds)
# so the agent's own voice echoing back the phone line can't trigger a false barge-in that
# cancels its reply (= caller hears silence). Set HALF_DUPLEX=false to allow barge-in.
HALF_DUPLEX = os.environ.get("HALF_DUPLEX", "true").lower() not in ("false", "0", "no")
ECHO_TAIL_SECS = float(os.environ.get("ECHO_TAIL_SECS", "0.5"))
# Agent persona name — purely for warmth; change/remove freely.
AGENT_NAME = os.environ.get("AGENT_NAME", "Sofia")
@@ -304,6 +310,35 @@ class AudioHeartbeat(FrameProcessor):
await self.push_frame(frame, direction)
class HalfDuplexGate(FrameProcessor):
"""Drops inbound audio while the agent is speaking (plus ECHO_TAIL_SECS after it stops).
In this pipecat build interruptions are VAD-driven and always on (PipelineParams has no
allow_interruptions). On a phone line the agent's own TTS echoes back and the VAD reads it
as the caller speaking → it broadcasts an interruption that cancels the agent mid-reply, so
the caller hears silence. Sitting BEFORE the VAD, this gate withholds inbound audio frames
while the bot is speaking, so its echo never reaches the VAD. Trade-off: the caller can't
barge in mid-utterance (fine for short receptionist replies). Bypass with HALF_DUPLEX=false."""
def __init__(self, tail_secs: float = 0.5):
super().__init__()
self._bot_speaking = False
self._reopen_at = 0.0
self._tail = tail_secs
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, BotStartedSpeakingFrame):
self._bot_speaking = True
elif isinstance(frame, BotStoppedSpeakingFrame):
self._bot_speaking = False
self._reopen_at = time.time() + self._tail
# Withhold caller audio while the bot speaks (+ echo tail) so echo can't barge in.
if isinstance(frame, InputAudioRawFrame) and (self._bot_speaking or time.time() < self._reopen_at):
return
await self.push_frame(frame, direction)
class HintedWhisperSTTService(WhisperSTTService):
"""WhisperSTTService that biases transcription toward domain vocabulary via
faster-whisper `hotwords`. Pipecat's service doesn't expose hotwords, so we wrap
@@ -466,6 +501,7 @@ async def run_agent(transport, caller_number=None, call_sid=None, do_capture=Tru
min_volume=VAD_MIN_VOLUME,
)))
heartbeat = AudioHeartbeat()
gate = HalfDuplexGate(tail_secs=ECHO_TAIL_SECS) if HALF_DUPLEX else None
# Per-call system message = static prompt + the caller-ID number to confirm. Inject it
# ALREADY spelled out digit-by-digit so the model repeats clean words instead of mangling
@@ -505,6 +541,7 @@ async def run_agent(transport, caller_number=None, call_sid=None, do_capture=Tru
[
transport.input(),
heartbeat,
*( [gate] if gate else [] ), # half-duplex echo gate, before the VAD
vad,
stt,
agg.user(),