"""Optional bot acknowledgments; never invoke the LLM or change user turns. Placed immediately before TTS. A pending timer is cancelled on silence or a reply. Once handed to TTS, the very short acknowledgment may finish normally. """ from __future__ import annotations import asyncio import time from collections.abc import Callable from pipecat.frames.frames import ( BotBackchannelFrame, BotStartedSpeakingFrame, BotStoppedSpeakingFrame, CancelFrame, EndFrame, Frame, InterruptionFrame, LLMFullResponseStartFrame, TTSSpeakFrame, UserStartedSpeakingFrame, UserStoppedSpeakingFrame, VADUserStartedSpeakingFrame, VADUserStoppedSpeakingFrame, ) from pipecat.processors.frame_processor import FrameDirection, FrameProcessor class BotBackchannelProcessor(FrameProcessor): """One short acknowledgment per long user turn, with a global cooldown.""" def __init__(self, *, delay_secs: float = 4, can_speak: Callable[[], bool]): super().__init__() self._delay = delay_secs self._can_speak = can_speak self._user_turn = False self._voice_active = False self._used = False self._bot_busy = False self._closed = False self._last_sent = float("-inf") self._timer: asyncio.Task | None = None async def _cancel_pending(self): timer, self._timer = self._timer, None if timer and timer is not asyncio.current_task(): await self.cancel_task(timer) def _schedule(self): if (not self._timer and self._user_turn and self._voice_active and not self._used and not self._bot_busy and not self._closed): self._timer = self.create_task(self._acknowledge()) async def _acknowledge(self): try: await asyncio.sleep(self._delay) if (self._user_turn and self._voice_active and not self._used and not self._bot_busy and not self._closed and self._can_speak() and time.monotonic() - self._last_sent >= 10): self._used = True self._last_sent = time.monotonic() # Reserve output before synthesis starts; playback events follow later. self._bot_busy = True await self.push_frame(BotBackchannelFrame(text="嗯。")) finally: self._timer = None async def process_frame(self, frame: Frame, direction: FrameDirection): await super().process_frame(frame, direction) # Playback boundaries travel upstream; user/VAD frames can be broadcast # both ways. Handle user signals only on the downstream path. if isinstance(frame, BotStartedSpeakingFrame): self._bot_busy = True await self._cancel_pending() elif isinstance(frame, BotStoppedSpeakingFrame): self._bot_busy = False self._schedule() elif isinstance(frame, (EndFrame, CancelFrame)): self._closed = True await self._cancel_pending() elif direction == FrameDirection.DOWNSTREAM: if isinstance(frame, UserStartedSpeakingFrame): self._user_turn, self._used = True, False self._schedule() elif isinstance(frame, VADUserStartedSpeakingFrame): self._voice_active = True self._schedule() elif isinstance(frame, VADUserStoppedSpeakingFrame): self._voice_active = False await self._cancel_pending() elif isinstance(frame, UserStoppedSpeakingFrame): self._user_turn = False await self._cancel_pending() elif isinstance(frame, (LLMFullResponseStartFrame, TTSSpeakFrame)): self._bot_busy = True await self._cancel_pending() elif isinstance(frame, InterruptionFrame): # User-start strategies broadcast an interruption after the # turn-start signal, even when no bot audio was playing. await self._cancel_pending() self._bot_busy = False self._schedule() await self.push_frame(frame, direction) async def cleanup(self): self._closed = True await self._cancel_pending() await super().cleanup()