diff --git a/server/src/wled_controller/core/capture_engines/wgc_engine.py b/server/src/wled_controller/core/capture_engines/wgc_engine.py
index cdc0b87..84454f5 100644
--- a/server/src/wled_controller/core/capture_engines/wgc_engine.py
+++ b/server/src/wled_controller/core/capture_engines/wgc_engine.py
@@ -43,14 +43,22 @@ class WGCCaptureStream(CaptureStream):
try:
capture_cursor = self.config.get("capture_cursor", False)
+ draw_border = self.config.get("draw_border", None)
# WGC uses 1-based monitor indexing
wgc_monitor_index = self.display_index + 1
- self._capture_instance = self._wgc.WindowsCapture(
+ # draw_border toggling requires Windows 11 22H2+ (build 22621+).
+ # On older builds, passing any value crashes the capture session,
+ # so we only pass it when the platform supports it.
+ wgc_kwargs = dict(
cursor_capture=capture_cursor,
monitor_index=wgc_monitor_index,
)
+ if draw_border is not None and self._supports_border_toggle():
+ wgc_kwargs["draw_border"] = draw_border
+
+ self._capture_instance = self._wgc.WindowsCapture(**wgc_kwargs)
def on_frame_arrived(frame, capture_control):
try:
@@ -101,6 +109,16 @@ class WGCCaptureStream(CaptureStream):
logger.error(f"Failed to initialize WGC for display {self.display_index}: {e}", exc_info=True)
raise RuntimeError(f"Failed to initialize WGC for display {self.display_index}: {e}")
+ @staticmethod
+ def _supports_border_toggle() -> bool:
+ """Check if the platform supports WGC border toggle (Windows 11 22H2+, build 22621+)."""
+ try:
+ import platform
+ build = int(platform.version().split(".")[2])
+ return build >= 22621
+ except Exception:
+ return False
+
def _cleanup_internal(self) -> None:
"""Internal cleanup helper."""
if self._capture_control:
@@ -137,12 +155,15 @@ class WGCCaptureStream(CaptureStream):
self.initialize()
try:
+ # Only return a frame when the callback has delivered a new one
+ if not self._frame_event.is_set():
+ return None
+
with self._frame_lock:
if self._latest_frame is None:
- raise RuntimeError(
- f"No frame available yet for display {self.display_index}."
- )
+ return None
frame = self._latest_frame
+ self._frame_event.clear()
logger.debug(
f"WGC captured display {self.display_index}: "
@@ -208,7 +229,7 @@ class WGCEngine(CaptureEngine):
def get_default_config(cls) -> Dict[str, Any]:
return {
"capture_cursor": False,
- "draw_border": False,
+ "draw_border": None, # None = OS default; False = hide border (Win 11 22H2+ only)
}
@classmethod
diff --git a/server/src/wled_controller/core/processing/color_strip_stream.py b/server/src/wled_controller/core/processing/color_strip_stream.py
index 24059d6..53e58aa 100644
--- a/server/src/wled_controller/core/processing/color_strip_stream.py
+++ b/server/src/wled_controller/core/processing/color_strip_stream.py
@@ -22,6 +22,7 @@ from wled_controller.core.capture.calibration import CalibrationConfig, PixelMap
from wled_controller.core.capture.screen_capture import extract_border_pixels
from wled_controller.core.processing.live_stream import LiveStream
from wled_controller.utils import get_logger
+from wled_controller.utils.timer import high_resolution_timer
logger = get_logger(__name__)
@@ -259,131 +260,132 @@ class PictureColorStripStream(ColorStripStream):
"""Background thread: poll source, process, cache colors."""
cached_frame = None
- while self._running:
- loop_start = time.perf_counter()
- fps = self._fps
- frame_time = 1.0 / fps if fps > 0 else 1.0
+ with high_resolution_timer():
+ while self._running:
+ loop_start = time.perf_counter()
+ fps = self._fps
+ frame_time = 1.0 / fps if fps > 0 else 1.0
- try:
- frame = self._live_stream.get_latest_frame()
+ try:
+ frame = self._live_stream.get_latest_frame()
- if frame is None or frame is cached_frame:
+ if frame is None or frame is cached_frame:
+ if (
+ frame is not None
+ and self._frame_interpolation
+ and self._interp_from is not None
+ and self._interp_to is not None
+ ):
+ t = min(1.0, (loop_start - self._interp_start) / self._interp_duration)
+ alpha = int(t * 256)
+ led_colors = (
+ (256 - alpha) * self._interp_from.astype(np.uint16)
+ + alpha * self._interp_to.astype(np.uint16)
+ ) >> 8
+ led_colors = led_colors.astype(np.uint8)
+ if self._saturation != 1.0:
+ led_colors = _apply_saturation(led_colors, self._saturation)
+ if self._gamma != 1.0:
+ led_colors = self._gamma_lut[led_colors]
+ if self._brightness != 1.0:
+ led_colors = np.clip(
+ led_colors.astype(np.float32) * self._brightness, 0, 255
+ ).astype(np.uint8)
+ with self._colors_lock:
+ self._latest_colors = led_colors
+ elapsed = time.perf_counter() - loop_start
+ time.sleep(max(frame_time - elapsed, 0.001))
+ continue
+
+ interval = (
+ loop_start - self._last_capture_time
+ if self._last_capture_time > 0
+ else frame_time
+ )
+ self._last_capture_time = loop_start
+ cached_frame = frame
+
+ t0 = time.perf_counter()
+
+ calibration = self._calibration
+ border_pixels = extract_border_pixels(frame, calibration.border_width)
+ t1 = time.perf_counter()
+
+ led_colors = self._pixel_mapper.map_border_to_leds(border_pixels)
+ t2 = time.perf_counter()
+
+ # Pad or truncate to match the declared led_count
+ target_count = self._led_count
+ if target_count > 0 and len(led_colors) != target_count:
+ if len(led_colors) < target_count:
+ pad = np.zeros((target_count - len(led_colors), 3), dtype=np.uint8)
+ led_colors = np.concatenate([led_colors, pad])
+ else:
+ led_colors = led_colors[:target_count]
+
+ # Update interpolation buffers (raw colors, before corrections)
+ if self._frame_interpolation:
+ self._interp_from = self._interp_to
+ self._interp_to = led_colors.copy()
+ self._interp_start = loop_start
+ self._interp_duration = max(interval, 0.001)
+
+ # Temporal smoothing
+ smoothing = self._smoothing
if (
- frame is not None
- and self._frame_interpolation
- and self._interp_from is not None
- and self._interp_to is not None
+ self._previous_colors is not None
+ and smoothing > 0
+ and len(self._previous_colors) == len(led_colors)
):
- t = min(1.0, (loop_start - self._interp_start) / self._interp_duration)
- alpha = int(t * 256)
+ alpha = int(smoothing * 256)
led_colors = (
- (256 - alpha) * self._interp_from.astype(np.uint16)
- + alpha * self._interp_to.astype(np.uint16)
+ (256 - alpha) * led_colors.astype(np.uint16)
+ + alpha * self._previous_colors.astype(np.uint16)
) >> 8
led_colors = led_colors.astype(np.uint8)
- if self._saturation != 1.0:
- led_colors = _apply_saturation(led_colors, self._saturation)
- if self._gamma != 1.0:
- led_colors = self._gamma_lut[led_colors]
- if self._brightness != 1.0:
- led_colors = np.clip(
- led_colors.astype(np.float32) * self._brightness, 0, 255
- ).astype(np.uint8)
- with self._colors_lock:
- self._latest_colors = led_colors
- elapsed = time.perf_counter() - loop_start
- time.sleep(max(frame_time - elapsed, 0.001))
- continue
+ t3 = time.perf_counter()
- interval = (
- loop_start - self._last_capture_time
- if self._last_capture_time > 0
- else frame_time
- )
- self._last_capture_time = loop_start
- cached_frame = frame
+ # Saturation
+ saturation = self._saturation
+ if saturation != 1.0:
+ led_colors = _apply_saturation(led_colors, saturation)
+ t4 = time.perf_counter()
- t0 = time.perf_counter()
+ # Gamma (LUT lookup — O(1) per pixel)
+ if self._gamma != 1.0:
+ led_colors = self._gamma_lut[led_colors]
+ t5 = time.perf_counter()
- calibration = self._calibration
- border_pixels = extract_border_pixels(frame, calibration.border_width)
- t1 = time.perf_counter()
+ # Brightness
+ brightness = self._brightness
+ if brightness != 1.0:
+ led_colors = np.clip(
+ led_colors.astype(np.float32) * brightness, 0, 255
+ ).astype(np.uint8)
+ t6 = time.perf_counter()
- led_colors = self._pixel_mapper.map_border_to_leds(border_pixels)
- t2 = time.perf_counter()
+ self._previous_colors = led_colors
- # Pad or truncate to match the declared led_count
- target_count = self._led_count
- if target_count > 0 and len(led_colors) != target_count:
- if len(led_colors) < target_count:
- pad = np.zeros((target_count - len(led_colors), 3), dtype=np.uint8)
- led_colors = np.concatenate([led_colors, pad])
- else:
- led_colors = led_colors[:target_count]
+ with self._colors_lock:
+ self._latest_colors = led_colors
- # Update interpolation buffers (raw colors, before corrections)
- if self._frame_interpolation:
- self._interp_from = self._interp_to
- self._interp_to = led_colors.copy()
- self._interp_start = loop_start
- self._interp_duration = max(interval, 0.001)
+ self._last_timing = {
+ "extract_ms": (t1 - t0) * 1000,
+ "map_leds_ms": (t2 - t1) * 1000,
+ "smooth_ms": (t3 - t2) * 1000,
+ "saturation_ms": (t4 - t3) * 1000,
+ "gamma_ms": (t5 - t4) * 1000,
+ "brightness_ms": (t6 - t5) * 1000,
+ "total_ms": (t6 - t0) * 1000,
+ }
- # Temporal smoothing
- smoothing = self._smoothing
- if (
- self._previous_colors is not None
- and smoothing > 0
- and len(self._previous_colors) == len(led_colors)
- ):
- alpha = int(smoothing * 256)
- led_colors = (
- (256 - alpha) * led_colors.astype(np.uint16)
- + alpha * self._previous_colors.astype(np.uint16)
- ) >> 8
- led_colors = led_colors.astype(np.uint8)
- t3 = time.perf_counter()
+ except Exception as e:
+ logger.error(f"PictureColorStripStream processing error: {e}", exc_info=True)
- # Saturation
- saturation = self._saturation
- if saturation != 1.0:
- led_colors = _apply_saturation(led_colors, saturation)
- t4 = time.perf_counter()
-
- # Gamma (LUT lookup — O(1) per pixel)
- if self._gamma != 1.0:
- led_colors = self._gamma_lut[led_colors]
- t5 = time.perf_counter()
-
- # Brightness
- brightness = self._brightness
- if brightness != 1.0:
- led_colors = np.clip(
- led_colors.astype(np.float32) * brightness, 0, 255
- ).astype(np.uint8)
- t6 = time.perf_counter()
-
- self._previous_colors = led_colors
-
- with self._colors_lock:
- self._latest_colors = led_colors
-
- self._last_timing = {
- "extract_ms": (t1 - t0) * 1000,
- "map_leds_ms": (t2 - t1) * 1000,
- "smooth_ms": (t3 - t2) * 1000,
- "saturation_ms": (t4 - t3) * 1000,
- "gamma_ms": (t5 - t4) * 1000,
- "brightness_ms": (t6 - t5) * 1000,
- "total_ms": (t6 - t0) * 1000,
- }
-
- except Exception as e:
- logger.error(f"PictureColorStripStream processing error: {e}", exc_info=True)
-
- elapsed = time.perf_counter() - loop_start
- remaining = frame_time - elapsed
- if remaining > 0:
- time.sleep(remaining)
+ elapsed = time.perf_counter() - loop_start
+ remaining = frame_time - elapsed
+ if remaining > 0:
+ time.sleep(remaining)
def _compute_gradient_colors(stops: list, led_count: int) -> np.ndarray:
@@ -537,28 +539,29 @@ class StaticColorStripStream(ColorStripStream):
def _animate_loop(self) -> None:
"""Background thread: compute animated colors at ~30 fps when animation is active."""
frame_time = 1.0 / 30
- while self._running:
- loop_start = time.time()
- anim = self._animation
- if anim and anim.get("enabled"):
- speed = float(anim.get("speed", 1.0))
- atype = anim.get("type", "breathing")
- t = loop_start
- n = self._led_count
- colors = None
+ with high_resolution_timer():
+ while self._running:
+ loop_start = time.perf_counter()
+ anim = self._animation
+ if anim and anim.get("enabled"):
+ speed = float(anim.get("speed", 1.0))
+ atype = anim.get("type", "breathing")
+ t = loop_start
+ n = self._led_count
+ colors = None
- if atype == "breathing":
- factor = 0.5 * (1 + math.sin(2 * math.pi * speed * t * 0.5))
- base = np.array(self._source_color, dtype=np.float32)
- pixel = np.clip(base * factor, 0, 255).astype(np.uint8)
- colors = np.tile(pixel, (n, 1))
+ if atype == "breathing":
+ factor = 0.5 * (1 + math.sin(2 * math.pi * speed * t * 0.5))
+ base = np.array(self._source_color, dtype=np.float32)
+ pixel = np.clip(base * factor, 0, 255).astype(np.uint8)
+ colors = np.tile(pixel, (n, 1))
- if colors is not None:
- with self._colors_lock:
- self._colors = colors
+ if colors is not None:
+ with self._colors_lock:
+ self._colors = colors
- elapsed = time.time() - loop_start
- time.sleep(max(frame_time - elapsed, 0.001))
+ elapsed = time.perf_counter() - loop_start
+ time.sleep(max(frame_time - elapsed, 0.001))
class ColorCycleColorStripStream(ColorStripStream):
@@ -651,26 +654,27 @@ class ColorCycleColorStripStream(ColorStripStream):
def _animate_loop(self) -> None:
"""Background thread: interpolate between colors at ~30 fps."""
frame_time = 1.0 / 30
- while self._running:
- loop_start = time.time()
- color_list = self._color_list
- speed = self._cycle_speed
- n = self._led_count
- num = len(color_list)
- if num >= 2:
- # 0.05 factor → one full cycle every 20s at speed=1.0
- cycle_pos = (speed * loop_start * 0.05) % 1.0
- seg = cycle_pos * num
- idx = int(seg) % num
- t_interp = seg - int(seg)
- c1 = np.array(color_list[idx], dtype=np.float32)
- c2 = np.array(color_list[(idx + 1) % num], dtype=np.float32)
- pixel = np.clip(c1 * (1 - t_interp) + c2 * t_interp, 0, 255).astype(np.uint8)
- led_colors = np.tile(pixel, (n, 1))
- with self._colors_lock:
- self._colors = led_colors
- elapsed = time.time() - loop_start
- time.sleep(max(frame_time - elapsed, 0.001))
+ with high_resolution_timer():
+ while self._running:
+ loop_start = time.perf_counter()
+ color_list = self._color_list
+ speed = self._cycle_speed
+ n = self._led_count
+ num = len(color_list)
+ if num >= 2:
+ # 0.05 factor → one full cycle every 20s at speed=1.0
+ cycle_pos = (speed * loop_start * 0.05) % 1.0
+ seg = cycle_pos * num
+ idx = int(seg) % num
+ t_interp = seg - int(seg)
+ c1 = np.array(color_list[idx], dtype=np.float32)
+ c2 = np.array(color_list[(idx + 1) % num], dtype=np.float32)
+ pixel = np.clip(c1 * (1 - t_interp) + c2 * t_interp, 0, 255).astype(np.uint8)
+ led_colors = np.tile(pixel, (n, 1))
+ with self._colors_lock:
+ self._colors = led_colors
+ elapsed = time.perf_counter() - loop_start
+ time.sleep(max(frame_time - elapsed, 0.001))
class GradientColorStripStream(ColorStripStream):
@@ -765,47 +769,48 @@ class GradientColorStripStream(ColorStripStream):
_cached_base: Optional[np.ndarray] = None
_cached_n: int = 0
_cached_stops: Optional[list] = None
- while self._running:
- loop_start = time.time()
- anim = self._animation
- if anim and anim.get("enabled"):
- speed = float(anim.get("speed", 1.0))
- atype = anim.get("type", "breathing")
- t = loop_start
- n = self._led_count
- stops = self._stops
- colors = None
+ with high_resolution_timer():
+ while self._running:
+ loop_start = time.perf_counter()
+ anim = self._animation
+ if anim and anim.get("enabled"):
+ speed = float(anim.get("speed", 1.0))
+ atype = anim.get("type", "breathing")
+ t = loop_start
+ n = self._led_count
+ stops = self._stops
+ colors = None
- # Recompute base gradient only when stops or led_count change
- if _cached_base is None or _cached_n != n or _cached_stops is not stops:
- _cached_base = _compute_gradient_colors(stops, n)
- _cached_n = n
- _cached_stops = stops
- base = _cached_base
+ # Recompute base gradient only when stops or led_count change
+ if _cached_base is None or _cached_n != n or _cached_stops is not stops:
+ _cached_base = _compute_gradient_colors(stops, n)
+ _cached_n = n
+ _cached_stops = stops
+ base = _cached_base
- if atype == "breathing":
- factor = 0.5 * (1 + math.sin(2 * math.pi * speed * t * 0.5))
- colors = np.clip(base.astype(np.float32) * factor, 0, 255).astype(np.uint8)
+ if atype == "breathing":
+ factor = 0.5 * (1 + math.sin(2 * math.pi * speed * t * 0.5))
+ colors = np.clip(base.astype(np.float32) * factor, 0, 255).astype(np.uint8)
- elif atype == "gradient_shift":
- shift = int(speed * t * 10) % max(n, 1)
- colors = np.roll(base, shift, axis=0)
+ elif atype == "gradient_shift":
+ shift = int(speed * t * 10) % max(n, 1)
+ colors = np.roll(base, shift, axis=0)
- elif atype == "wave":
- if n > 1:
- i_arr = np.arange(n, dtype=np.float32)
- factor = 0.5 * (1 + np.sin(
- 2 * math.pi * i_arr / n - 2 * math.pi * speed * t
- ))
- colors = np.clip(
- base.astype(np.float32) * factor[:, None], 0, 255
- ).astype(np.uint8)
- else:
- colors = base
+ elif atype == "wave":
+ if n > 1:
+ i_arr = np.arange(n, dtype=np.float32)
+ factor = 0.5 * (1 + np.sin(
+ 2 * math.pi * i_arr / n - 2 * math.pi * speed * t
+ ))
+ colors = np.clip(
+ base.astype(np.float32) * factor[:, None], 0, 255
+ ).astype(np.uint8)
+ else:
+ colors = base
- if colors is not None:
- with self._colors_lock:
- self._colors = colors
+ if colors is not None:
+ with self._colors_lock:
+ self._colors = colors
- elapsed = time.time() - loop_start
- time.sleep(max(frame_time - elapsed, 0.001))
+ elapsed = time.perf_counter() - loop_start
+ time.sleep(max(frame_time - elapsed, 0.001))
diff --git a/server/src/wled_controller/core/processing/kc_target_processor.py b/server/src/wled_controller/core/processing/kc_target_processor.py
index d7bbc0f..6cbf830 100644
--- a/server/src/wled_controller/core/processing/kc_target_processor.py
+++ b/server/src/wled_controller/core/processing/kc_target_processor.py
@@ -24,6 +24,7 @@ from wled_controller.core.processing.target_processor import (
TargetProcessor,
)
from wled_controller.utils import get_logger
+from wled_controller.utils.timer import high_resolution_timer
logger = get_logger(__name__)
@@ -270,7 +271,7 @@ class KCTargetProcessor(TargetProcessor):
frame_time = 1.0 / target_fps
fps_samples: collections.deque = collections.deque(maxlen=10)
timing_samples: collections.deque = collections.deque(maxlen=10)
- prev_frame_time_stamp = time.time()
+ prev_frame_time_stamp = time.perf_counter()
prev_capture = None
last_broadcast_time = 0.0
send_timestamps: collections.deque = collections.deque()
@@ -299,94 +300,95 @@ class KCTargetProcessor(TargetProcessor):
)
try:
- while self._is_running:
- loop_start = time.time()
+ with high_resolution_timer():
+ while self._is_running:
+ loop_start = time.perf_counter()
- try:
- capture = self._live_stream.get_latest_frame()
+ try:
+ capture = self._live_stream.get_latest_frame()
- if capture is None:
- await asyncio.sleep(frame_time)
- continue
+ if capture is None:
+ await asyncio.sleep(frame_time)
+ continue
- # Skip processing if the frame hasn't changed
- if capture is prev_capture:
- # Keepalive: re-broadcast last colors
- if self._latest_colors and (loop_start - last_broadcast_time) >= 1.0:
- await self._broadcast_colors(self._latest_colors)
- last_broadcast_time = time.time()
- send_timestamps.append(last_broadcast_time)
- self._metrics.frames_keepalive += 1
- self._metrics.frames_skipped += 1
- now_ts = time.time()
- while send_timestamps and send_timestamps[0] < now_ts - 1.0:
+ # Skip processing if the frame hasn't changed
+ if capture is prev_capture:
+ # Keepalive: re-broadcast last colors
+ if self._latest_colors and (loop_start - last_broadcast_time) >= 1.0:
+ await self._broadcast_colors(self._latest_colors)
+ last_broadcast_time = time.perf_counter()
+ send_timestamps.append(last_broadcast_time)
+ self._metrics.frames_keepalive += 1
+ self._metrics.frames_skipped += 1
+ now_ts = time.perf_counter()
+ while send_timestamps and send_timestamps[0] < now_ts - 1.0:
+ send_timestamps.popleft()
+ self._metrics.fps_current = len(send_timestamps)
+ await asyncio.sleep(frame_time)
+ continue
+ prev_capture = capture
+
+ # Read settings fresh each frame so hot updates (brightness,
+ # smoothing, interpolation_mode) take effect without restart.
+ s = self._settings
+ calc_fn = calc_fns.get(s.interpolation_mode, calculate_average_color)
+
+ # CPU-bound work in thread pool
+ colors, colors_arr, frame_timing = await asyncio.to_thread(
+ _process_kc_frame,
+ capture, rect_names, rect_bounds, calc_fn,
+ prev_colors_arr, s.smoothing, s.brightness,
+ )
+
+ prev_colors_arr = colors_arr
+ self._latest_colors = dict(colors)
+
+ # Broadcast to WebSocket clients
+ t_broadcast_start = time.perf_counter()
+ await self._broadcast_colors(colors)
+ broadcast_ms = (time.perf_counter() - t_broadcast_start) * 1000
+ last_broadcast_time = time.perf_counter()
+ send_timestamps.append(last_broadcast_time)
+
+ # Per-stage timing (rolling average over last 10 frames)
+ frame_timing["broadcast"] = broadcast_ms
+ timing_samples.append(frame_timing)
+ n = len(timing_samples)
+ self._metrics.timing_calc_colors_ms = sum(s["calc_colors"] for s in timing_samples) / n
+ self._metrics.timing_smooth_ms = sum(s["smooth"] for s in timing_samples) / n
+ self._metrics.timing_broadcast_ms = sum(s["broadcast"] for s in timing_samples) / n
+ self._metrics.timing_total_ms = sum(s["total"] for s in timing_samples) / n + broadcast_ms
+
+ # Update metrics
+ self._metrics.frames_processed += 1
+ self._metrics.last_update = datetime.utcnow()
+
+ # Calculate actual FPS
+ now = time.perf_counter()
+ interval = now - prev_frame_time_stamp
+ prev_frame_time_stamp = now
+ fps_samples.append(1.0 / interval if interval > 0 else 0)
+ self._metrics.fps_actual = sum(fps_samples) / len(fps_samples)
+
+ # Potential FPS
+ processing_time = now - loop_start
+ self._metrics.fps_potential = 1.0 / processing_time if processing_time > 0 else 0
+
+ # fps_current
+ while send_timestamps and send_timestamps[0] < now - 1.0:
send_timestamps.popleft()
self._metrics.fps_current = len(send_timestamps)
- await asyncio.sleep(frame_time)
- continue
- prev_capture = capture
- # Read settings fresh each frame so hot updates (brightness,
- # smoothing, interpolation_mode) take effect without restart.
- s = self._settings
- calc_fn = calc_fns.get(s.interpolation_mode, calculate_average_color)
+ except Exception as e:
+ self._metrics.errors_count += 1
+ self._metrics.last_error = str(e)
+ logger.error(f"KC processing error for {self._target_id}: {e}", exc_info=True)
- # CPU-bound work in thread pool
- colors, colors_arr, frame_timing = await asyncio.to_thread(
- _process_kc_frame,
- capture, rect_names, rect_bounds, calc_fn,
- prev_colors_arr, s.smoothing, s.brightness,
- )
-
- prev_colors_arr = colors_arr
- self._latest_colors = dict(colors)
-
- # Broadcast to WebSocket clients
- t_broadcast_start = time.perf_counter()
- await self._broadcast_colors(colors)
- broadcast_ms = (time.perf_counter() - t_broadcast_start) * 1000
- last_broadcast_time = time.time()
- send_timestamps.append(last_broadcast_time)
-
- # Per-stage timing (rolling average over last 10 frames)
- frame_timing["broadcast"] = broadcast_ms
- timing_samples.append(frame_timing)
- n = len(timing_samples)
- self._metrics.timing_calc_colors_ms = sum(s["calc_colors"] for s in timing_samples) / n
- self._metrics.timing_smooth_ms = sum(s["smooth"] for s in timing_samples) / n
- self._metrics.timing_broadcast_ms = sum(s["broadcast"] for s in timing_samples) / n
- self._metrics.timing_total_ms = sum(s["total"] for s in timing_samples) / n + broadcast_ms
-
- # Update metrics
- self._metrics.frames_processed += 1
- self._metrics.last_update = datetime.utcnow()
-
- # Calculate actual FPS
- now = time.time()
- interval = now - prev_frame_time_stamp
- prev_frame_time_stamp = now
- fps_samples.append(1.0 / interval if interval > 0 else 0)
- self._metrics.fps_actual = sum(fps_samples) / len(fps_samples)
-
- # Potential FPS
- processing_time = now - loop_start
- self._metrics.fps_potential = 1.0 / processing_time if processing_time > 0 else 0
-
- # fps_current
- while send_timestamps and send_timestamps[0] < now - 1.0:
- send_timestamps.popleft()
- self._metrics.fps_current = len(send_timestamps)
-
- except Exception as e:
- self._metrics.errors_count += 1
- self._metrics.last_error = str(e)
- logger.error(f"KC processing error for {self._target_id}: {e}", exc_info=True)
-
- # Throttle to target FPS
- elapsed = time.time() - loop_start
- remaining = frame_time - elapsed
- if remaining > 0:
- await asyncio.sleep(remaining)
+ # Throttle to target FPS
+ elapsed = time.perf_counter() - 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 {self._target_id}")
diff --git a/server/src/wled_controller/core/processing/live_stream.py b/server/src/wled_controller/core/processing/live_stream.py
index 91dee5f..56cc6db 100644
--- a/server/src/wled_controller/core/processing/live_stream.py
+++ b/server/src/wled_controller/core/processing/live_stream.py
@@ -22,6 +22,7 @@ import numpy as np
from wled_controller.core.capture_engines.base import CaptureStream, ScreenCapture
from wled_controller.core.filters import ImagePool, PostprocessingFilter
from wled_controller.utils import get_logger
+from wled_controller.utils.timer import high_resolution_timer
logger = get_logger(__name__)
@@ -128,24 +129,25 @@ class ScreenCaptureLiveStream(LiveStream):
def _capture_loop(self) -> None:
frame_time = 1.0 / self._fps if self._fps > 0 else 1.0
- while self._running:
- loop_start = time.time()
- try:
- frame = self._capture_stream.capture_frame()
- if frame is not None:
- with self._frame_lock:
- self._latest_frame = frame
- else:
- # Small sleep when no frame available to avoid CPU spinning
- time.sleep(0.001)
- except Exception as e:
- logger.error(f"Capture error (display={self._capture_stream.display_index}): {e}")
+ with high_resolution_timer():
+ while self._running:
+ loop_start = time.perf_counter()
+ try:
+ frame = self._capture_stream.capture_frame()
+ if frame is not None:
+ with self._frame_lock:
+ self._latest_frame = frame
+ else:
+ # Small sleep when no frame available to avoid CPU spinning
+ time.sleep(0.001)
+ except Exception as e:
+ logger.error(f"Capture error (display={self._capture_stream.display_index}): {e}")
- # Throttle to target FPS
- elapsed = time.time() - loop_start
- remaining = frame_time - elapsed
- if remaining > 0:
- time.sleep(remaining)
+ # Throttle to target FPS
+ elapsed = time.perf_counter() - loop_start
+ remaining = frame_time - elapsed
+ if remaining > 0:
+ time.sleep(remaining)
class ProcessedLiveStream(LiveStream):
@@ -224,78 +226,79 @@ class ProcessedLiveStream(LiveStream):
fps = self.target_fps
frame_time = 1.0 / fps if fps > 0 else 1.0
- while self._running:
- loop_start = time.time()
+ with high_resolution_timer():
+ while self._running:
+ loop_start = time.perf_counter()
- source_frame = self._source.get_latest_frame()
- if source_frame is None or source_frame is cached_source_frame:
- # Idle tick — run filter chain when any filter requests idle processing
- if self._has_idle_filters and cached_source_frame is not None:
- src = cached_source_frame.image
- h, w, c = src.shape
- if _idle_src_buf is None or _idle_src_buf.shape != (h, w, c):
- _idle_src_buf = np.empty((h, w, c), dtype=np.uint8)
- np.copyto(_idle_src_buf, src)
- idle_image = _idle_src_buf
+ source_frame = self._source.get_latest_frame()
+ if source_frame is None or source_frame is cached_source_frame:
+ # Idle tick — run filter chain when any filter requests idle processing
+ if self._has_idle_filters and cached_source_frame is not None:
+ src = cached_source_frame.image
+ h, w, c = src.shape
+ if _idle_src_buf is None or _idle_src_buf.shape != (h, w, c):
+ _idle_src_buf = np.empty((h, w, c), dtype=np.uint8)
+ np.copyto(_idle_src_buf, src)
+ idle_image = _idle_src_buf
- for f in self._filters:
- result = f.process_image(idle_image, self._image_pool)
- if result is not None:
- if idle_image is not _idle_src_buf:
- self._image_pool.release(idle_image)
- idle_image = result
+ for f in self._filters:
+ result = f.process_image(idle_image, self._image_pool)
+ if result is not None:
+ if idle_image is not _idle_src_buf:
+ self._image_pool.release(idle_image)
+ idle_image = result
- # Only publish a new frame when the filter chain produced actual
- # interpolated output (idle_image advanced past the input buffer).
- # If every filter passed through, idle_image is still _idle_src_buf —
- # leave _latest_frame unchanged so consumers that rely on object
- # identity for deduplication correctly detect no new content.
- if idle_image is not _idle_src_buf:
- processed = ScreenCapture(
- image=idle_image,
- width=cached_source_frame.width,
- height=cached_source_frame.height,
- display_index=cached_source_frame.display_index,
- )
- with self._frame_lock:
- self._latest_frame = processed
+ # Only publish a new frame when the filter chain produced actual
+ # interpolated output (idle_image advanced past the input buffer).
+ # If every filter passed through, idle_image is still _idle_src_buf —
+ # leave _latest_frame unchanged so consumers that rely on object
+ # identity for deduplication correctly detect no new content.
+ if idle_image is not _idle_src_buf:
+ processed = ScreenCapture(
+ image=idle_image,
+ width=cached_source_frame.width,
+ height=cached_source_frame.height,
+ display_index=cached_source_frame.display_index,
+ )
+ with self._frame_lock:
+ self._latest_frame = processed
- elapsed = time.time() - loop_start
- remaining = frame_time - elapsed
- time.sleep(max(remaining, 0.001))
- continue
+ elapsed = time.perf_counter() - loop_start
+ remaining = frame_time - elapsed
+ time.sleep(max(remaining, 0.001))
+ continue
- cached_source_frame = source_frame
+ cached_source_frame = source_frame
- # Reuse ring buffer slot instead of allocating a new copy each frame
- src = source_frame.image
- h, w, c = src.shape
- buf = _ring[_ring_idx]
- if buf is None or buf.shape != (h, w, c):
- buf = np.empty((h, w, c), dtype=np.uint8)
- _ring[_ring_idx] = buf
- _ring_idx = (_ring_idx + 1) % 3
+ # Reuse ring buffer slot instead of allocating a new copy each frame
+ src = source_frame.image
+ h, w, c = src.shape
+ buf = _ring[_ring_idx]
+ if buf is None or buf.shape != (h, w, c):
+ buf = np.empty((h, w, c), dtype=np.uint8)
+ _ring[_ring_idx] = buf
+ _ring_idx = (_ring_idx + 1) % 3
- np.copyto(buf, src)
- image = buf
+ np.copyto(buf, src)
+ image = buf
- for f in self._filters:
- result = f.process_image(image, self._image_pool)
- if result is not None:
- # Release intermediate filter output back to pool
- # (don't release the ring buffer itself)
- if image is not buf:
- self._image_pool.release(image)
- image = result
+ for f in self._filters:
+ result = f.process_image(image, self._image_pool)
+ if result is not None:
+ # Release intermediate filter output back to pool
+ # (don't release the ring buffer itself)
+ if image is not buf:
+ self._image_pool.release(image)
+ image = result
- processed = ScreenCapture(
- image=image,
- width=source_frame.width,
- height=source_frame.height,
- display_index=source_frame.display_index,
- )
- with self._frame_lock:
- self._latest_frame = processed
+ processed = ScreenCapture(
+ image=image,
+ width=source_frame.width,
+ height=source_frame.height,
+ display_index=source_frame.display_index,
+ )
+ with self._frame_lock:
+ self._latest_frame = processed
class StaticImageLiveStream(LiveStream):
diff --git a/server/src/wled_controller/core/processing/wled_target_processor.py b/server/src/wled_controller/core/processing/wled_target_processor.py
index f429f11..9696f97 100644
--- a/server/src/wled_controller/core/processing/wled_target_processor.py
+++ b/server/src/wled_controller/core/processing/wled_target_processor.py
@@ -19,6 +19,7 @@ from wled_controller.core.processing.target_processor import (
TargetProcessor,
)
from wled_controller.utils import get_logger
+from wled_controller.utils.timer import high_resolution_timer
logger = get_logger(__name__)
@@ -367,7 +368,7 @@ class WledTargetProcessor(TargetProcessor):
send_timestamps: collections.deque = collections.deque()
prev_colors = None
last_send_time = 0.0
- prev_frame_time_stamp = time.time()
+ prev_frame_time_stamp = time.perf_counter()
loop = asyncio.get_running_loop()
logger.info(
@@ -376,102 +377,103 @@ class WledTargetProcessor(TargetProcessor):
)
try:
- while self._is_running:
- loop_start = now = time.time()
- # Re-read target_fps each tick so hot-updates to the CSS source take effect
- target_fps = stream.target_fps if stream.target_fps > 0 else 30
- frame_time = 1.0 / target_fps
+ with high_resolution_timer():
+ while self._is_running:
+ loop_start = now = time.perf_counter()
+ # Re-read target_fps each tick so hot-updates to the CSS source take effect
+ target_fps = stream.target_fps if stream.target_fps > 0 else 30
+ frame_time = 1.0 / target_fps
- # Re-fetch device info for runtime changes (test mode, brightness)
- device_info = self._ctx.get_device_info(self._device_id)
+ # Re-fetch device info for runtime changes (test mode, brightness)
+ device_info = self._ctx.get_device_info(self._device_id)
- # Skip send while in calibration test mode
- if device_info and device_info.test_mode_active:
- await asyncio.sleep(frame_time)
- continue
-
- try:
- colors = stream.get_latest_colors()
-
- if colors is None:
- if self._metrics.frames_processed == 0:
- logger.info(f"Stream returned None for target {self._target_id} (no data yet)")
+ # Skip send while in calibration test mode
+ if device_info and device_info.test_mode_active:
await asyncio.sleep(frame_time)
continue
- if colors is prev_colors:
- # Same frame — send keepalive if interval elapsed
- if prev_colors is not None and (loop_start - last_send_time) >= standby_interval:
- if not self._is_running or self._led_client is None:
- break
- send_colors = self._apply_brightness(prev_colors, device_info)
- if self._led_client.supports_fast_send:
- self._led_client.send_pixels_fast(send_colors)
- else:
- await self._led_client.send_pixels(send_colors)
- now = time.time()
- last_send_time = now
- send_timestamps.append(now)
- self._metrics.frames_keepalive += 1
- self._metrics.frames_skipped += 1
+ try:
+ colors = stream.get_latest_colors()
+
+ if colors is None:
+ if self._metrics.frames_processed == 0:
+ logger.info(f"Stream returned None for target {self._target_id} (no data yet)")
+ await asyncio.sleep(frame_time)
+ continue
+
+ if colors is prev_colors:
+ # Same frame — send keepalive if interval elapsed
+ if prev_colors is not None and (loop_start - last_send_time) >= standby_interval:
+ if not self._is_running or self._led_client is None:
+ break
+ send_colors = self._apply_brightness(prev_colors, device_info)
+ if self._led_client.supports_fast_send:
+ self._led_client.send_pixels_fast(send_colors)
+ else:
+ await self._led_client.send_pixels(send_colors)
+ now = time.perf_counter()
+ last_send_time = now
+ send_timestamps.append(now)
+ self._metrics.frames_keepalive += 1
+ self._metrics.frames_skipped += 1
+ while send_timestamps and send_timestamps[0] < now - 1.0:
+ send_timestamps.popleft()
+ self._metrics.fps_current = len(send_timestamps)
+ await asyncio.sleep(frame_time)
+ continue
+
+ prev_colors = colors
+
+ # Apply device software brightness
+ send_colors = self._apply_brightness(colors, device_info)
+
+ # Send to LED device
+ if not self._is_running or self._led_client is None:
+ break
+ t_send_start = time.perf_counter()
+ if self._led_client.supports_fast_send:
+ self._led_client.send_pixels_fast(send_colors)
+ else:
+ await self._led_client.send_pixels(send_colors)
+ send_ms = (time.perf_counter() - t_send_start) * 1000
+
+ now = time.perf_counter()
+ last_send_time = now
+ send_timestamps.append(now)
+
+ self._metrics.timing_send_ms = send_ms
+ self._metrics.frames_processed += 1
+ self._metrics.last_update = datetime.utcnow()
+
+ if self._metrics.frames_processed <= 3 or self._metrics.frames_processed % 100 == 0:
+ logger.info(
+ f"Frame {self._metrics.frames_processed} for {self._target_id} "
+ f"({len(send_colors)} LEDs) — send={send_ms:.1f}ms"
+ )
+
+ # FPS tracking
+ interval = now - prev_frame_time_stamp
+ prev_frame_time_stamp = now
+ fps_samples.append(1.0 / interval if interval > 0 else 0)
+ self._metrics.fps_actual = sum(fps_samples) / len(fps_samples)
+
+ processing_time = now - loop_start
+ self._metrics.fps_potential = 1.0 / processing_time if processing_time > 0 else 0
+
while send_timestamps and send_timestamps[0] < now - 1.0:
send_timestamps.popleft()
self._metrics.fps_current = len(send_timestamps)
- await asyncio.sleep(frame_time)
- continue
- prev_colors = colors
+ except Exception as e:
+ self._metrics.errors_count += 1
+ self._metrics.last_error = str(e)
+ logger.error(f"Processing error for target {self._target_id}: {e}", exc_info=True)
- # Apply device software brightness
- send_colors = self._apply_brightness(colors, device_info)
-
- # Send to LED device
- if not self._is_running or self._led_client is None:
- break
- t_send_start = time.perf_counter()
- if self._led_client.supports_fast_send:
- self._led_client.send_pixels_fast(send_colors)
- else:
- await self._led_client.send_pixels(send_colors)
- send_ms = (time.perf_counter() - t_send_start) * 1000
-
- now = time.time()
- last_send_time = now
- send_timestamps.append(now)
-
- self._metrics.timing_send_ms = send_ms
- self._metrics.frames_processed += 1
- self._metrics.last_update = datetime.utcnow()
-
- if self._metrics.frames_processed <= 3 or self._metrics.frames_processed % 100 == 0:
- logger.info(
- f"Frame {self._metrics.frames_processed} for {self._target_id} "
- f"({len(send_colors)} LEDs) — send={send_ms:.1f}ms"
- )
-
- # FPS tracking
- interval = now - prev_frame_time_stamp
- prev_frame_time_stamp = now
- fps_samples.append(1.0 / interval if interval > 0 else 0)
- self._metrics.fps_actual = sum(fps_samples) / len(fps_samples)
-
- processing_time = now - loop_start
- self._metrics.fps_potential = 1.0 / processing_time if processing_time > 0 else 0
-
- while send_timestamps and send_timestamps[0] < now - 1.0:
- send_timestamps.popleft()
- self._metrics.fps_current = len(send_timestamps)
-
- except Exception as e:
- self._metrics.errors_count += 1
- self._metrics.last_error = str(e)
- logger.error(f"Processing error for target {self._target_id}: {e}", exc_info=True)
-
- # Throttle to target FPS
- elapsed = now - loop_start
- remaining = frame_time - elapsed
- if remaining > 0:
- await asyncio.sleep(remaining)
+ # Throttle to target FPS
+ elapsed = now - loop_start
+ remaining = frame_time - elapsed
+ if remaining > 0:
+ await asyncio.sleep(remaining)
except asyncio.CancelledError:
logger.info(f"Processing loop cancelled for target {self._target_id}")
diff --git a/server/src/wled_controller/static/js/features/color-strips.js b/server/src/wled_controller/static/js/features/color-strips.js
index 7cee700..81e1478 100644
--- a/server/src/wled_controller/static/js/features/color-strips.js
+++ b/server/src/wled_controller/static/js/features/color-strips.js
@@ -172,8 +172,10 @@ export function createColorStripCard(source, pictureSourceMap) {
const isGradient = source.source_type === 'gradient';
const isColorCycle = source.source_type === 'color_cycle';
- const animBadge = ((isStatic || isGradient) && source.animation && source.animation.enabled)
- ? `✨ ${t('color_strip.animation.type.' + source.animation.type) || source.animation.type}`
+ const anim = (isStatic || isGradient) && source.animation && source.animation.enabled ? source.animation : null;
+ const animBadge = anim
+ ? `✨ ${t('color_strip.animation.type.' + anim.type) || anim.type}`
+ + `⏩ ${(anim.speed || 1.0).toFixed(1)}×`
: '';
let propsHtml;
diff --git a/server/src/wled_controller/utils/__init__.py b/server/src/wled_controller/utils/__init__.py
index 6e2fec9..4b50806 100644
--- a/server/src/wled_controller/utils/__init__.py
+++ b/server/src/wled_controller/utils/__init__.py
@@ -2,5 +2,6 @@
from .logger import setup_logging, get_logger
from .monitor_names import get_monitor_names, get_monitor_name, get_monitor_refresh_rates
+from .timer import high_resolution_timer
-__all__ = ["setup_logging", "get_logger", "get_monitor_names", "get_monitor_name", "get_monitor_refresh_rates"]
+__all__ = ["setup_logging", "get_logger", "get_monitor_names", "get_monitor_name", "get_monitor_refresh_rates", "high_resolution_timer"]
diff --git a/server/src/wled_controller/utils/timer.py b/server/src/wled_controller/utils/timer.py
new file mode 100644
index 0000000..63d2855
--- /dev/null
+++ b/server/src/wled_controller/utils/timer.py
@@ -0,0 +1,34 @@
+"""High-resolution timer utilities for precise sleep on Windows.
+
+Windows default timer resolution is ~15.6ms, making time.sleep() very
+imprecise for real-time loops (e.g. 60fps needs 16.67ms per frame).
+
+Calling timeBeginPeriod(1) increases system timer resolution to 1ms,
+making time.sleep() accurate to ~1ms. The calls are reference-counted
+by Windows — each timeBeginPeriod must be paired with timeEndPeriod.
+"""
+
+import sys
+from contextlib import contextmanager
+
+
+@contextmanager
+def high_resolution_timer():
+ """Context manager that enables 1ms timer resolution on Windows.
+
+ Usage::
+
+ with high_resolution_timer():
+ while running:
+ ...
+ time.sleep(remaining) # now accurate to ~1ms
+ """
+ if sys.platform == "win32":
+ import ctypes
+ ctypes.windll.winmm.timeBeginPeriod(1)
+ try:
+ yield
+ finally:
+ if sys.platform == "win32":
+ import ctypes
+ ctypes.windll.winmm.timeEndPeriod(1)