From c4955bcb34699ff2d6d7c473bb16249c71e46570 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Wed, 18 Feb 2026 01:39:18 +0300 Subject: [PATCH] Add FPS throttling to capture, processing, and send loops All three frame pipeline loops were running unthrottled, consuming excessive CPU. Now each sleeps for the remaining frame budget after completing work. Co-Authored-By: Claude Opus 4.6 --- .../src/wled_controller/core/live_stream.py | 24 ++++++++++--------- .../wled_controller/core/processor_manager.py | 14 +++++++---- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/server/src/wled_controller/core/live_stream.py b/server/src/wled_controller/core/live_stream.py index 7ba5637..f420ad0 100644 --- a/server/src/wled_controller/core/live_stream.py +++ b/server/src/wled_controller/core/live_stream.py @@ -141,8 +141,11 @@ class ScreenCaptureLiveStream(LiveStream): except Exception as e: logger.error(f"Capture error (display={self._capture_stream.display_index}): {e}") - # No FPS throttling - capture as fast as frames are available - # But sleep briefly during idle periods to avoid burning CPU + # Throttle to target FPS + elapsed = time.time() - loop_start + remaining = frame_time - elapsed + if remaining > 0: + time.sleep(remaining) class ProcessedLiveStream(LiveStream): @@ -209,18 +212,17 @@ class ProcessedLiveStream(LiveStream): # processed by a consumer), so the 3rd slot is always safe to reuse. _ring: List[Optional[np.ndarray]] = [None, None, None] _ring_idx = 0 + frame_time = 1.0 / self._source.target_fps if self._source.target_fps > 0 else 1.0 while self._running: - source_frame = self._source.get_latest_frame() - if source_frame is None: - # Small sleep when waiting for frames to avoid CPU spinning - time.sleep(0.001) - continue + loop_start = time.time() - # Identity cache: Skip processing duplicate frames to save CPU - # (Compare object identity to detect when capture engine returns same frame) - if source_frame is cached_source_frame: - time.sleep(0.001) + source_frame = self._source.get_latest_frame() + if source_frame is None or source_frame is cached_source_frame: + # Sleep until next frame is expected + elapsed = time.time() - loop_start + remaining = frame_time - elapsed + time.sleep(max(remaining, 0.001)) continue cached_source_frame = source_frame diff --git a/server/src/wled_controller/core/processor_manager.py b/server/src/wled_controller/core/processor_manager.py index 7ebe11e..8f35d17 100644 --- a/server/src/wled_controller/core/processor_manager.py +++ b/server/src/wled_controller/core/processor_manager.py @@ -859,8 +859,11 @@ class ProcessorManager: state.metrics.last_error = str(e) logger.error(f"Processing error for target {target_id}: {e}", exc_info=True) - # No FPS control - process frames as fast as they arrive (match test behavior) - pass + # Throttle to target FPS + elapsed = time.time() - loop_start + remaining = frame_time - elapsed + if remaining > 0: + await asyncio.sleep(remaining) except asyncio.CancelledError: logger.info(f"Processing loop cancelled for target {target_id}") @@ -1503,8 +1506,11 @@ class ProcessorManager: state.metrics.last_error = str(e) logger.error(f"KC processing error for {target_id}: {e}", exc_info=True) - # No FPS control - process frames as fast as they arrive (match test behavior) - pass + # Throttle to target FPS + elapsed = time.time() - loop_start + remaining = frame_time - elapsed + if remaining > 0: + await asyncio.sleep(remaining) except asyncio.CancelledError: logger.info(f"KC processing loop cancelled for target {target_id}")