Merge pull request #3744 from pipecat-ai/mb/user-idle-timeout-frame
Redesign UserIdleController to use BotStoppedSpeakingFrame
This commit is contained in:
@@ -696,6 +696,9 @@ class LLMUserAggregator(LLMContextAggregator):
|
||||
if params.enable_user_speaking_frames:
|
||||
await self.broadcast_frame(UserStartedSpeakingFrame)
|
||||
|
||||
if self._user_idle_controller:
|
||||
await self._user_idle_controller.process_frame(UserStartedSpeakingFrame())
|
||||
|
||||
if params.enable_interruptions and self._allow_interruptions:
|
||||
await self.push_interruption_task_frame_and_wait()
|
||||
|
||||
@@ -712,6 +715,9 @@ class LLMUserAggregator(LLMContextAggregator):
|
||||
if params.enable_user_speaking_frames:
|
||||
await self.broadcast_frame(UserStoppedSpeakingFrame)
|
||||
|
||||
if self._user_idle_controller:
|
||||
await self._user_idle_controller.process_frame(UserStoppedSpeakingFrame())
|
||||
|
||||
await self._maybe_emit_user_turn_stopped(strategy)
|
||||
|
||||
async def _on_user_turn_stop_timeout(self, controller):
|
||||
|
||||
@@ -10,12 +10,14 @@ import asyncio
|
||||
from typing import Optional
|
||||
|
||||
from pipecat.frames.frames import (
|
||||
BotSpeakingFrame,
|
||||
BotStartedSpeakingFrame,
|
||||
BotStoppedSpeakingFrame,
|
||||
Frame,
|
||||
FunctionCallCancelFrame,
|
||||
FunctionCallResultFrame,
|
||||
FunctionCallsStartedFrame,
|
||||
UserSpeakingFrame,
|
||||
UserStartedSpeakingFrame,
|
||||
UserStoppedSpeakingFrame,
|
||||
)
|
||||
from pipecat.utils.asyncio.task_manager import BaseTaskManager
|
||||
from pipecat.utils.base_object import BaseObject
|
||||
@@ -25,14 +27,14 @@ class UserIdleController(BaseObject):
|
||||
"""Controller for managing user idle detection.
|
||||
|
||||
This class monitors user activity and triggers an event when the user has been
|
||||
idle (not speaking) for a configured timeout period. It only starts monitoring
|
||||
after the first conversation activity and does not trigger while the bot is
|
||||
speaking or function calls are in progress.
|
||||
idle (not speaking) for a configured timeout period after the bot finishes
|
||||
speaking. The timer starts when BotStoppedSpeakingFrame is received and is
|
||||
cancelled when someone starts speaking again (UserStartedSpeakingFrame or
|
||||
BotStartedSpeakingFrame).
|
||||
|
||||
The controller tracks activity using continuous frames (UserSpeakingFrame and
|
||||
BotSpeakingFrame) which are emitted repeatedly while speaking is happening, and
|
||||
state-based tracking for function calls (FunctionCallsStartedFrame and
|
||||
FunctionCallResultFrame) which are only sent at start and end.
|
||||
The timer is suppressed while a user turn is in progress to avoid false
|
||||
triggers during interruptions (where BotStoppedSpeakingFrame arrives while
|
||||
the user is still speaking).
|
||||
|
||||
Event handlers available:
|
||||
|
||||
@@ -62,11 +64,9 @@ class UserIdleController(BaseObject):
|
||||
|
||||
self._task_manager: Optional[BaseTaskManager] = None
|
||||
|
||||
self._conversation_started = False
|
||||
self._function_call_in_progress = False
|
||||
|
||||
self.user_idle_event = asyncio.Event()
|
||||
self.user_idle_task: Optional[asyncio.Task] = None
|
||||
self._user_turn_in_progress: bool = False
|
||||
self._function_calls_in_progress: int = 0
|
||||
self._idle_timer_task: Optional[asyncio.Task] = None
|
||||
|
||||
self._register_event_handler("on_user_turn_idle", sync=True)
|
||||
|
||||
@@ -85,19 +85,10 @@ class UserIdleController(BaseObject):
|
||||
"""
|
||||
self._task_manager = task_manager
|
||||
|
||||
if not self.user_idle_task:
|
||||
self.user_idle_task = self.task_manager.create_task(
|
||||
self.user_idle_task_handler(),
|
||||
f"{self}::user_idle_task_handler",
|
||||
)
|
||||
|
||||
async def cleanup(self):
|
||||
"""Cleanup the controller."""
|
||||
await super().cleanup()
|
||||
|
||||
if self.user_idle_task:
|
||||
await self.task_manager.cancel_task(self.user_idle_task)
|
||||
self.user_idle_task = None
|
||||
await self._cancel_idle_timer()
|
||||
|
||||
async def process_frame(self, frame: Frame):
|
||||
"""Process an incoming frame to track user activity state.
|
||||
@@ -105,69 +96,52 @@ class UserIdleController(BaseObject):
|
||||
Args:
|
||||
frame: The frame to be processed.
|
||||
"""
|
||||
# Start monitoring on first conversation activity
|
||||
if not self._conversation_started:
|
||||
if isinstance(frame, (UserStartedSpeakingFrame, BotSpeakingFrame)):
|
||||
self._conversation_started = True
|
||||
self.user_idle_event.set()
|
||||
else:
|
||||
return
|
||||
|
||||
# Reset idle timer on continuous activity frames
|
||||
if isinstance(frame, (UserSpeakingFrame, BotSpeakingFrame)):
|
||||
await self._handle_activity(frame)
|
||||
# Track function call state (start/end frames, not continuous)
|
||||
if isinstance(frame, BotStoppedSpeakingFrame):
|
||||
# Only start the timer if the user isn't mid-turn and no function
|
||||
# calls are pending.
|
||||
#
|
||||
# Interruption case: the frame order is UserStartedSpeaking →
|
||||
# BotStoppedSpeaking → (user keeps talking) → UserStoppedSpeaking.
|
||||
# Without the user-turn guard the timer would start while the user
|
||||
# is still speaking.
|
||||
#
|
||||
# Function call case: normally FunctionCallsStarted arrives after
|
||||
# BotStoppedSpeaking and cancels the timer directly. But a race
|
||||
# condition can cause FunctionCallsStarted to arrive before
|
||||
# BotStoppedSpeaking when pushing a TTSSpeakFrame in the
|
||||
# on_function_calls_started event handler, so the counter guard
|
||||
# prevents the timer from starting while a function call is in progress.
|
||||
if not self._user_turn_in_progress and self._function_calls_in_progress == 0:
|
||||
await self._start_idle_timer()
|
||||
elif isinstance(frame, BotStartedSpeakingFrame):
|
||||
await self._cancel_idle_timer()
|
||||
elif isinstance(frame, UserStartedSpeakingFrame):
|
||||
self._user_turn_in_progress = True
|
||||
await self._cancel_idle_timer()
|
||||
elif isinstance(frame, UserStoppedSpeakingFrame):
|
||||
self._user_turn_in_progress = False
|
||||
elif isinstance(frame, FunctionCallsStartedFrame):
|
||||
await self._handle_function_calls_started(frame)
|
||||
elif isinstance(frame, FunctionCallResultFrame):
|
||||
await self._handle_function_call_result(frame)
|
||||
self._function_calls_in_progress += len(frame.function_calls)
|
||||
await self._cancel_idle_timer()
|
||||
elif isinstance(frame, (FunctionCallResultFrame, FunctionCallCancelFrame)):
|
||||
self._function_calls_in_progress = max(0, self._function_calls_in_progress - 1)
|
||||
|
||||
async def _handle_activity(self, _: UserSpeakingFrame | BotSpeakingFrame):
|
||||
"""Handle continuous activity frames that should reset the idle timer.
|
||||
async def _start_idle_timer(self):
|
||||
"""Start (or restart) the idle timer."""
|
||||
await self._cancel_idle_timer()
|
||||
self._idle_timer_task = self.task_manager.create_task(
|
||||
self._idle_timer_expired(),
|
||||
f"{self}::idle_timer",
|
||||
)
|
||||
|
||||
These frames are emitted continuously while the user or bot is speaking,
|
||||
so we simply reset the timer whenever we receive them.
|
||||
async def _cancel_idle_timer(self):
|
||||
"""Cancel the idle timer if running."""
|
||||
if self._idle_timer_task:
|
||||
await self.task_manager.cancel_task(self._idle_timer_task)
|
||||
self._idle_timer_task = None
|
||||
|
||||
Args:
|
||||
frame: The activity frame to process.
|
||||
"""
|
||||
self.user_idle_event.set()
|
||||
|
||||
async def _handle_function_calls_started(self, _: FunctionCallsStartedFrame):
|
||||
"""Handle function calls started event.
|
||||
|
||||
Function calls can take longer than the timeout, so we track their state
|
||||
to prevent idle callbacks while they're in progress.
|
||||
|
||||
Args:
|
||||
frame: The FunctionCallsStartedFrame to process.
|
||||
"""
|
||||
self._function_call_in_progress = True
|
||||
self.user_idle_event.set()
|
||||
|
||||
async def _handle_function_call_result(self, _: FunctionCallResultFrame):
|
||||
"""Handle function call result event.
|
||||
|
||||
Args:
|
||||
frame: The FunctionCallResultFrame to process.
|
||||
"""
|
||||
self._function_call_in_progress = False
|
||||
self.user_idle_event.set()
|
||||
|
||||
async def user_idle_task_handler(self):
|
||||
"""Monitors for idle timeout and triggers events.
|
||||
|
||||
Runs in a loop until cancelled. The idle timer is reset whenever activity
|
||||
frames are received (UserSpeakingFrame or BotSpeakingFrame). Function calls
|
||||
are tracked via state since they only send start/end frames. If no activity
|
||||
is detected for the configured timeout period and no function call is in
|
||||
progress, the on_user_turn_idle event is triggered.
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.wait_for(self.user_idle_event.wait(), timeout=self._user_idle_timeout)
|
||||
self.user_idle_event.clear()
|
||||
except asyncio.TimeoutError:
|
||||
# Only trigger if conversation has started and no function call is in progress
|
||||
if self._conversation_started and not self._function_call_in_progress:
|
||||
await self._call_event_handler("on_user_turn_idle")
|
||||
async def _idle_timer_expired(self):
|
||||
"""Sleep for the timeout duration then fire the idle event."""
|
||||
await asyncio.sleep(self._user_idle_timeout)
|
||||
self._idle_timer_task = None
|
||||
await self._call_event_handler("on_user_turn_idle")
|
||||
|
||||
@@ -189,6 +189,9 @@ class UserTurnProcessor(FrameProcessor):
|
||||
if params.enable_user_speaking_frames:
|
||||
await self.broadcast_frame(UserStartedSpeakingFrame)
|
||||
|
||||
if self._user_idle_controller:
|
||||
await self._user_idle_controller.process_frame(UserStartedSpeakingFrame())
|
||||
|
||||
if params.enable_interruptions and self._allow_interruptions:
|
||||
await self.push_interruption_task_frame_and_wait()
|
||||
|
||||
@@ -205,6 +208,9 @@ class UserTurnProcessor(FrameProcessor):
|
||||
if params.enable_user_speaking_frames:
|
||||
await self.broadcast_frame(UserStoppedSpeakingFrame)
|
||||
|
||||
if self._user_idle_controller:
|
||||
await self._user_idle_controller.process_frame(UserStoppedSpeakingFrame())
|
||||
|
||||
await self._call_event_handler("on_user_turn_stopped", strategy)
|
||||
|
||||
async def _on_user_turn_stop_timeout(self, controller):
|
||||
|
||||
Reference in New Issue
Block a user