Skip to content

Commit 5077656

Browse files
committed
Make UserIdleController always-on with dynamic timeout updates
Always create UserIdleController (timeout=0 means disabled), removing all Optional guards. Add UserIdleTimeoutUpdateFrame to allow changing the idle timeout at runtime.
1 parent cb70236 commit 5077656

8 files changed

Lines changed: 129 additions & 48 deletions

File tree

changelog/3748.added.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Added `UserIdleTimeoutUpdateFrame` to enable or disable user idle detection at runtime by updating the timeout dynamically.

changelog/3748.changed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- `UserIdleController` is now always created with a default timeout of 0 (disabled). The `user_idle_timeout` parameter changed from `Optional[float] = None` to `float = 0` in `UserTurnProcessor`, `LLMUserAggregatorParams`, and `UserIdleController`.

examples/foundational/17-detect-user-idle.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
LLMMessagesAppendFrame,
2020
LLMRunFrame,
2121
TTSSpeakFrame,
22+
UserIdleTimeoutUpdateFrame,
2223
)
2324
from pipecat.pipeline.pipeline import Pipeline
2425
from pipecat.pipeline.runner import PipelineRunner
@@ -210,6 +211,12 @@ async def on_client_connected(transport, client):
210211
# Kick off the conversation.
211212
messages.append({"role": "system", "content": "Please introduce yourself to the user."})
212213
await task.queue_frames([LLMRunFrame()])
214+
await asyncio.sleep(30)
215+
logger.info(f"Disabling idle detection")
216+
await task.queue_frames([UserIdleTimeoutUpdateFrame(timeout=0)])
217+
await asyncio.sleep(30)
218+
logger.info(f"Enabling idle detection")
219+
await task.queue_frames([UserIdleTimeoutUpdateFrame(timeout=5)])
213220

214221
@transport.event_handler("on_client_disconnected")
215222
async def on_client_disconnected(transport, client):

src/pipecat/frames/frames.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2145,6 +2145,20 @@ class STTUpdateSettingsFrame(ServiceUpdateSettingsFrame):
21452145
pass
21462146

21472147

2148+
@dataclass
2149+
class UserIdleTimeoutUpdateFrame(SystemFrame):
2150+
"""Frame for updating the user idle timeout at runtime.
2151+
2152+
Setting timeout to 0 disables idle detection. Setting a positive value
2153+
enables it.
2154+
2155+
Parameters:
2156+
timeout: The new idle timeout in seconds. 0 disables idle detection.
2157+
"""
2158+
2159+
timeout: float
2160+
2161+
21482162
@dataclass
21492163
class VADParamsUpdateFrame(ControlFrame):
21502164
"""Frame for updating VAD parameters.

src/pipecat/processors/aggregators/llm_response_universal.py

Lines changed: 13 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,9 @@ class LLMUserAggregatorParams:
9292
user_mute_strategies: List of user mute strategies.
9393
user_turn_stop_timeout: Time in seconds to wait before considering the
9494
user's turn finished.
95-
user_idle_timeout: Optional timeout in seconds for detecting user idle state.
96-
If set, the aggregator will emit an `on_user_turn_idle` event when the user
97-
has been idle (not speaking) for this duration. Set to None to disable
95+
user_idle_timeout: Timeout in seconds for detecting user idle state.
96+
The aggregator will emit an `on_user_turn_idle` event when the user
97+
has been idle (not speaking) for this duration. Set to 0 to disable
9898
idle detection.
9999
vad_analyzer: Voice Activity Detection analyzer instance.
100100
filter_incomplete_user_turns: Whether to filter out incomplete user turns.
@@ -109,7 +109,7 @@ class LLMUserAggregatorParams:
109109
user_turn_strategies: Optional[UserTurnStrategies] = None
110110
user_mute_strategies: List[BaseUserMuteStrategy] = field(default_factory=list)
111111
user_turn_stop_timeout: float = 5.0
112-
user_idle_timeout: Optional[float] = None
112+
user_idle_timeout: float = 0
113113
vad_analyzer: Optional[VADAnalyzer] = None
114114
filter_incomplete_user_turns: bool = False
115115
user_turn_completion_config: Optional[UserTurnCompletionConfig] = None
@@ -404,15 +404,10 @@ def __init__(
404404
"on_user_turn_stop_timeout", self._on_user_turn_stop_timeout
405405
)
406406

407-
# Optional user idle controller
408-
self._user_idle_controller: Optional[UserIdleController] = None
409-
if self._params.user_idle_timeout:
410-
self._user_idle_controller = UserIdleController(
411-
user_idle_timeout=self._params.user_idle_timeout
412-
)
413-
self._user_idle_controller.add_event_handler(
414-
"on_user_turn_idle", self._on_user_turn_idle
415-
)
407+
self._user_idle_controller = UserIdleController(
408+
user_idle_timeout=self._params.user_idle_timeout
409+
)
410+
self._user_idle_controller.add_event_handler("on_user_turn_idle", self._on_user_turn_idle)
416411

417412
# VAD controller
418413
self._vad_controller: Optional[VADController] = None
@@ -489,8 +484,7 @@ async def process_frame(self, frame: Frame, direction: FrameDirection):
489484

490485
await self._user_turn_controller.process_frame(frame)
491486

492-
if self._user_idle_controller:
493-
await self._user_idle_controller.process_frame(frame)
487+
await self._user_idle_controller.process_frame(frame)
494488

495489
async def push_aggregation(self) -> str:
496490
"""Push the current aggregation."""
@@ -507,8 +501,7 @@ async def push_aggregation(self) -> str:
507501
async def _start(self, frame: StartFrame):
508502
await self._user_turn_controller.setup(self.task_manager)
509503

510-
if self._user_idle_controller:
511-
await self._user_idle_controller.setup(self.task_manager)
504+
await self._user_idle_controller.setup(self.task_manager)
512505

513506
for s in self._params.user_mute_strategies:
514507
await s.setup(self.task_manager)
@@ -541,9 +534,7 @@ async def _cancel(self, frame: CancelFrame):
541534

542535
async def _cleanup(self):
543536
await self._user_turn_controller.cleanup()
544-
545-
if self._user_idle_controller:
546-
await self._user_idle_controller.cleanup()
537+
await self._user_idle_controller.cleanup()
547538

548539
for s in self._params.user_mute_strategies:
549540
await s.cleanup()
@@ -689,8 +680,7 @@ async def _on_user_turn_started(
689680
if params.enable_user_speaking_frames:
690681
await self.broadcast_frame(UserStartedSpeakingFrame)
691682

692-
if self._user_idle_controller:
693-
await self._user_idle_controller.process_frame(UserStartedSpeakingFrame())
683+
await self._user_idle_controller.process_frame(UserStartedSpeakingFrame())
694684

695685
if params.enable_interruptions and self._allow_interruptions:
696686
await self.push_interruption_task_frame_and_wait()
@@ -708,8 +698,7 @@ async def _on_user_turn_stopped(
708698
if params.enable_user_speaking_frames:
709699
await self.broadcast_frame(UserStoppedSpeakingFrame)
710700

711-
if self._user_idle_controller:
712-
await self._user_idle_controller.process_frame(UserStoppedSpeakingFrame())
701+
await self._user_idle_controller.process_frame(UserStoppedSpeakingFrame())
713702

714703
await self._maybe_emit_user_turn_stopped(strategy)
715704

src/pipecat/turns/user_idle_controller.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
FunctionCallCancelFrame,
1717
FunctionCallResultFrame,
1818
FunctionCallsStartedFrame,
19+
UserIdleTimeoutUpdateFrame,
1920
UserStartedSpeakingFrame,
2021
UserStoppedSpeakingFrame,
2122
)
@@ -51,12 +52,13 @@ async def on_user_turn_idle(controller):
5152
def __init__(
5253
self,
5354
*,
54-
user_idle_timeout: float,
55+
user_idle_timeout: float = 0,
5556
):
5657
"""Initialize the user idle controller.
5758
5859
Args:
5960
user_idle_timeout: Timeout in seconds before considering the user idle.
61+
0 disables idle detection.
6062
"""
6163
super().__init__()
6264

@@ -96,6 +98,12 @@ async def process_frame(self, frame: Frame):
9698
Args:
9799
frame: The frame to be processed.
98100
"""
101+
if isinstance(frame, UserIdleTimeoutUpdateFrame):
102+
self._user_idle_timeout = frame.timeout
103+
if self._user_idle_timeout <= 0:
104+
await self._cancel_idle_timer()
105+
return
106+
99107
if isinstance(frame, BotStoppedSpeakingFrame):
100108
# Only start the timer if the user isn't mid-turn and no function
101109
# calls are pending.
@@ -128,6 +136,8 @@ async def process_frame(self, frame: Frame):
128136

129137
async def _start_idle_timer(self):
130138
"""Start (or restart) the idle timer."""
139+
if self._user_idle_timeout <= 0:
140+
return
131141
await self._cancel_idle_timer()
132142
self._idle_timer_task = self.task_manager.create_task(
133143
self._idle_timer_expired(),

src/pipecat/turns/user_turn_processor.py

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def __init__(
6666
*,
6767
user_turn_strategies: Optional[UserTurnStrategies] = None,
6868
user_turn_stop_timeout: float = 5.0,
69-
user_idle_timeout: Optional[float] = None,
69+
user_idle_timeout: float = 0,
7070
**kwargs,
7171
):
7272
"""Initialize the user turn processor.
@@ -75,9 +75,9 @@ def __init__(
7575
user_turn_strategies: Configured strategies for starting and stopping user turns.
7676
user_turn_stop_timeout: Timeout in seconds to automatically stop a user turn
7777
if no activity is detected.
78-
user_idle_timeout: Optional timeout in seconds for detecting user idle state.
79-
If set, the processor will emit an `on_user_turn_idle` event when the user
80-
has been idle (not speaking) for this duration. Set to None to disable
78+
user_idle_timeout: Timeout in seconds for detecting user idle state.
79+
The processor will emit an `on_user_turn_idle` event when the user
80+
has been idle (not speaking) for this duration. Set to 0 to disable
8181
idle detection.
8282
**kwargs: Additional keyword arguments.
8383
"""
@@ -104,13 +104,8 @@ def __init__(
104104
"on_user_turn_stop_timeout", self._on_user_turn_stop_timeout
105105
)
106106

107-
# Optional user idle controller
108-
self._user_idle_controller: Optional[UserIdleController] = None
109-
if user_idle_timeout:
110-
self._user_idle_controller = UserIdleController(user_idle_timeout=user_idle_timeout)
111-
self._user_idle_controller.add_event_handler(
112-
"on_user_turn_idle", self._on_user_turn_idle
113-
)
107+
self._user_idle_controller = UserIdleController(user_idle_timeout=user_idle_timeout)
108+
self._user_idle_controller.add_event_handler("on_user_turn_idle", self._on_user_turn_idle)
114109

115110
async def cleanup(self):
116111
"""Clean up processor resources."""
@@ -149,14 +144,11 @@ async def process_frame(self, frame: Frame, direction: FrameDirection):
149144

150145
await self._user_turn_controller.process_frame(frame)
151146

152-
if self._user_idle_controller:
153-
await self._user_idle_controller.process_frame(frame)
147+
await self._user_idle_controller.process_frame(frame)
154148

155149
async def _start(self, frame: StartFrame):
156150
await self._user_turn_controller.setup(self.task_manager)
157-
158-
if self._user_idle_controller:
159-
await self._user_idle_controller.setup(self.task_manager)
151+
await self._user_idle_controller.setup(self.task_manager)
160152

161153
async def _stop(self, frame: EndFrame):
162154
await self._cleanup()
@@ -166,9 +158,7 @@ async def _cancel(self, frame: CancelFrame):
166158

167159
async def _cleanup(self):
168160
await self._user_turn_controller.cleanup()
169-
170-
if self._user_idle_controller:
171-
await self._user_idle_controller.cleanup()
161+
await self._user_idle_controller.cleanup()
172162

173163
async def _on_push_frame(
174164
self, controller, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM
@@ -189,8 +179,7 @@ async def _on_user_turn_started(
189179
if params.enable_user_speaking_frames:
190180
await self.broadcast_frame(UserStartedSpeakingFrame)
191181

192-
if self._user_idle_controller:
193-
await self._user_idle_controller.process_frame(UserStartedSpeakingFrame())
182+
await self._user_idle_controller.process_frame(UserStartedSpeakingFrame())
194183

195184
if params.enable_interruptions and self._allow_interruptions:
196185
await self.push_interruption_task_frame_and_wait()
@@ -208,8 +197,7 @@ async def _on_user_turn_stopped(
208197
if params.enable_user_speaking_frames:
209198
await self.broadcast_frame(UserStoppedSpeakingFrame)
210199

211-
if self._user_idle_controller:
212-
await self._user_idle_controller.process_frame(UserStoppedSpeakingFrame())
200+
await self._user_idle_controller.process_frame(UserStoppedSpeakingFrame())
213201

214202
await self._call_event_handler("on_user_turn_stopped", strategy)
215203

tests/test_user_idle_controller.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
BotStoppedSpeakingFrame,
1414
FunctionCallResultFrame,
1515
FunctionCallsStartedFrame,
16+
UserIdleTimeoutUpdateFrame,
1617
UserStartedSpeakingFrame,
1718
)
1819
from pipecat.turns.user_idle_controller import UserIdleController
@@ -247,6 +248,76 @@ async def on_user_turn_idle(controller):
247248

248249
await controller.cleanup()
249250

251+
async def test_disabled_by_default(self):
252+
"""Test that timeout=0 means idle detection is disabled."""
253+
controller = UserIdleController()
254+
await controller.setup(self.task_manager)
255+
256+
idle_triggered = False
257+
258+
@controller.event_handler("on_user_turn_idle")
259+
async def on_user_turn_idle(controller):
260+
nonlocal idle_triggered
261+
idle_triggered = True
262+
263+
await controller.process_frame(BotStoppedSpeakingFrame())
264+
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
265+
266+
self.assertFalse(idle_triggered)
267+
268+
await controller.cleanup()
269+
270+
async def test_enable_via_frame(self):
271+
"""Test enabling idle detection at runtime via UserIdleTimeoutUpdateFrame."""
272+
controller = UserIdleController()
273+
await controller.setup(self.task_manager)
274+
275+
idle_triggered = False
276+
277+
@controller.event_handler("on_user_turn_idle")
278+
async def on_user_turn_idle(controller):
279+
nonlocal idle_triggered
280+
idle_triggered = True
281+
282+
# Initially disabled — no idle fires
283+
await controller.process_frame(BotStoppedSpeakingFrame())
284+
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
285+
self.assertFalse(idle_triggered)
286+
287+
# Enable idle detection
288+
await controller.process_frame(UserIdleTimeoutUpdateFrame(timeout=USER_IDLE_TIMEOUT))
289+
await controller.process_frame(BotStoppedSpeakingFrame())
290+
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
291+
292+
self.assertTrue(idle_triggered)
293+
294+
await controller.cleanup()
295+
296+
async def test_disable_via_frame(self):
297+
"""Test disabling idle detection at runtime via UserIdleTimeoutUpdateFrame."""
298+
controller = UserIdleController(user_idle_timeout=USER_IDLE_TIMEOUT)
299+
await controller.setup(self.task_manager)
300+
301+
idle_triggered = False
302+
303+
@controller.event_handler("on_user_turn_idle")
304+
async def on_user_turn_idle(controller):
305+
nonlocal idle_triggered
306+
idle_triggered = True
307+
308+
# Start the timer
309+
await controller.process_frame(BotStoppedSpeakingFrame())
310+
await asyncio.sleep(USER_IDLE_TIMEOUT * 0.3)
311+
312+
# Disable — should cancel running timer
313+
await controller.process_frame(UserIdleTimeoutUpdateFrame(timeout=0))
314+
315+
await asyncio.sleep(USER_IDLE_TIMEOUT + 0.1)
316+
317+
self.assertFalse(idle_triggered)
318+
319+
await controller.cleanup()
320+
250321

251322
if __name__ == "__main__":
252323
unittest.main()

0 commit comments

Comments
 (0)