Merge pull request #1032 from pipecat-ai/mb/user-idle-fixes

Start UserIdleProcessor on speaking frame, fix bug not pushing EndFrame
This commit is contained in:
Mark Backman
2025-01-17 13:18:09 -05:00
committed by GitHub
2 changed files with 76 additions and 24 deletions

View File

@@ -56,6 +56,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Modified `UserIdleProcessor` to start monitoring only after first
conversation activity (`UserStartedSpeakingFrame` or
`BotStartedSpeakingFrame`) instead of immediately.
- Modified `OpenAIAssistantContextAggregator` to support controlled completions - Modified `OpenAIAssistantContextAggregator` to support controlled completions
and to emit context update callbacks via `FunctionCallResultProperties`. and to emit context update callbacks via `FunctionCallResultProperties`.
@@ -79,6 +83,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- Fixed `UserIdleProcessor` not properly propagating `EndFrame`s through the
pipeline.
- Fixed an issue where websocket based TTS services could incorrectly terminate - Fixed an issue where websocket based TTS services could incorrectly terminate
their connection due to a retry counter not resetting. their connection due to a retry counter not resetting.

View File

@@ -12,7 +12,6 @@ from pipecat.frames.frames import (
CancelFrame, CancelFrame,
EndFrame, EndFrame,
Frame, Frame,
StartFrame,
UserStartedSpeakingFrame, UserStartedSpeakingFrame,
UserStoppedSpeakingFrame, UserStoppedSpeakingFrame,
) )
@@ -20,10 +19,24 @@ from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
class UserIdleProcessor(FrameProcessor): class UserIdleProcessor(FrameProcessor):
"""This class is useful to check if the user is interacting with the bot """Monitors user inactivity and triggers callbacks after timeout periods.
within a given timeout. If the timeout is reached before any interaction
occurred the provided callback will be called.
Starts monitoring only after the first conversation activity (UserStartedSpeaking
or BotSpeaking).
Args:
callback: Function to call when user is idle
timeout: Seconds to wait before considering user idle
**kwargs: Additional arguments passed to FrameProcessor
Example:
async def handle_idle(processor: "UserIdleProcessor") -> None:
await send_reminder("Are you still there?")
processor = UserIdleProcessor(
callback=handle_idle,
timeout=5.0
)
""" """
def __init__( def __init__(
@@ -37,40 +50,72 @@ class UserIdleProcessor(FrameProcessor):
self._callback = callback self._callback = callback
self._timeout = timeout self._timeout = timeout
self._interrupted = False self._interrupted = False
self._conversation_started = False
self._idle_task = None
self._idle_event = asyncio.Event()
def _create_idle_task(self):
"""Create the idle task if it hasn't been created yet."""
if self._idle_task is None:
self._idle_task = self.get_event_loop().create_task(self._idle_task_handler())
async def _stop(self): async def _stop(self):
self._idle_task.cancel() """Stops and cleans up the idle monitoring task."""
await self._idle_task if self._idle_task is not None:
self._idle_task.cancel()
try:
await self._idle_task
except asyncio.CancelledError:
pass # Expected when task is cancelled
self._idle_task = None
async def process_frame(self, frame: Frame, direction: FrameDirection): async def process_frame(self, frame: Frame, direction: FrameDirection):
"""Processes incoming frames and manages idle monitoring state.
Args:
frame: The frame to process
direction: Direction of the frame flow
"""
await super().process_frame(frame, direction) await super().process_frame(frame, direction)
# Check for end frames before processing # Check for end frames before processing
if isinstance(frame, StartFrame): if isinstance(frame, (EndFrame, CancelFrame)):
self._create_idle_task() await self.push_frame(frame, direction) # Push the frame down the pipeline
elif isinstance(frame, (EndFrame, CancelFrame)): if self._idle_task:
await self._stop() await self._stop() # Stop the idle task, if it exists
return
await self.push_frame(frame, direction) await self.push_frame(frame, direction)
# We shouldn't call the idle callback if the user or the bot are speaking # Start monitoring on first conversation activity
if isinstance(frame, UserStartedSpeakingFrame): if not self._conversation_started and isinstance(
self._interrupted = True frame, (UserStartedSpeakingFrame, BotSpeakingFrame)
self._idle_event.set() ):
elif isinstance(frame, UserStoppedSpeakingFrame): self._conversation_started = True
self._interrupted = False self._create_idle_task()
self._idle_event.set()
elif isinstance(frame, BotSpeakingFrame): # Only process these events if conversation has started
self._idle_event.set() if self._conversation_started:
# We shouldn't call the idle callback if the user or the bot are speaking
if isinstance(frame, UserStartedSpeakingFrame):
self._interrupted = True
self._idle_event.set()
elif isinstance(frame, UserStoppedSpeakingFrame):
self._interrupted = False
self._idle_event.set()
elif isinstance(frame, BotSpeakingFrame):
self._idle_event.set()
async def cleanup(self): async def cleanup(self):
await self._stop() """Cleans up resources when processor is shutting down."""
if self._idle_task: # Only stop if task exists
def _create_idle_task(self): await self._stop()
self._idle_event = asyncio.Event()
self._idle_task = self.get_event_loop().create_task(self._idle_task_handler())
async def _idle_task_handler(self): async def _idle_task_handler(self):
"""Monitors for idle timeout and triggers callbacks.
Runs in a loop until cancelled.
"""
while True: while True:
try: try:
await asyncio.wait_for(self._idle_event.wait(), timeout=self._timeout) await asyncio.wait_for(self._idle_event.wait(), timeout=self._timeout)