Add dynamic FPS to static, gradient, and color cycle streams

All three non-picture color strip stream types had their animation
loops hardcoded at 30 FPS and lacked set_capture_fps(), so target
FPS changes had no effect. Now each stream reads self._fps per
iteration and exposes set_capture_fps() for the stream manager.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-22 00:52:19 +03:00
parent ee52e2d98f
commit 2a01c2947a

View File

@@ -470,6 +470,7 @@ class StaticColorStripStream(ColorStripStream):
self._colors_lock = threading.Lock() self._colors_lock = threading.Lock()
self._running = False self._running = False
self._thread: Optional[threading.Thread] = None self._thread: Optional[threading.Thread] = None
self._fps = 30
self._update_from_source(source) self._update_from_source(source)
def _update_from_source(self, source) -> None: def _update_from_source(self, source) -> None:
@@ -502,12 +503,17 @@ class StaticColorStripStream(ColorStripStream):
@property @property
def target_fps(self) -> int: def target_fps(self) -> int:
return 30 return self._fps
@property @property
def led_count(self) -> int: def led_count(self) -> int:
return self._led_count return self._led_count
def set_capture_fps(self, fps: int) -> None:
"""Update animation loop rate. Thread-safe (read atomically by the loop)."""
fps = max(1, min(90, fps))
self._fps = fps
def start(self) -> None: def start(self) -> None:
if self._running: if self._running:
return return
@@ -545,13 +551,12 @@ class StaticColorStripStream(ColorStripStream):
logger.info("StaticColorStripStream params updated in-place") logger.info("StaticColorStripStream params updated in-place")
def _animate_loop(self) -> None: def _animate_loop(self) -> None:
"""Background thread: compute animated colors at ~30 fps when animation is active. """Background thread: compute animated colors at target fps when animation is active.
Uses double-buffered output arrays (buf_a / buf_b) to avoid per-frame Uses double-buffered output arrays (buf_a / buf_b) to avoid per-frame
numpy allocations while preserving the identity check used by the numpy allocations while preserving the identity check used by the
processing loop (``colors is prev_colors``). processing loop (``colors is prev_colors``).
""" """
frame_time = 1.0 / 30
# Double-buffer pool — re-allocated only when LED count changes # Double-buffer pool — re-allocated only when LED count changes
_pool_n = 0 _pool_n = 0
_buf_a = _buf_b = None _buf_a = _buf_b = None
@@ -560,6 +565,7 @@ class StaticColorStripStream(ColorStripStream):
with high_resolution_timer(): with high_resolution_timer():
while self._running: while self._running:
loop_start = time.perf_counter() loop_start = time.perf_counter()
frame_time = 1.0 / self._fps
anim = self._animation anim = self._animation
if anim and anim.get("enabled"): if anim and anim.get("enabled"):
speed = float(anim.get("speed", 1.0)) speed = float(anim.get("speed", 1.0))
@@ -645,7 +651,7 @@ class ColorCycleColorStripStream(ColorStripStream):
"""Color strip stream that smoothly cycles through a user-defined color list. """Color strip stream that smoothly cycles through a user-defined color list.
All LEDs receive the same solid color at any moment, continuously interpolating All LEDs receive the same solid color at any moment, continuously interpolating
between the configured colors in a loop at 30 fps. between the configured colors in a loop.
LED count auto-sizes from the connected device when led_count == 0 in LED count auto-sizes from the connected device when led_count == 0 in
the source config; configure(device_led_count) is called by the source config; configure(device_led_count) is called by
@@ -656,6 +662,7 @@ class ColorCycleColorStripStream(ColorStripStream):
self._colors_lock = threading.Lock() self._colors_lock = threading.Lock()
self._running = False self._running = False
self._thread: Optional[threading.Thread] = None self._thread: Optional[threading.Thread] = None
self._fps = 30
self._update_from_source(source) self._update_from_source(source)
def _update_from_source(self, source) -> None: def _update_from_source(self, source) -> None:
@@ -687,12 +694,17 @@ class ColorCycleColorStripStream(ColorStripStream):
@property @property
def target_fps(self) -> int: def target_fps(self) -> int:
return 30 return self._fps
@property @property
def led_count(self) -> int: def led_count(self) -> int:
return self._led_count return self._led_count
def set_capture_fps(self, fps: int) -> None:
"""Update animation loop rate. Thread-safe (read atomically by the loop)."""
fps = max(1, min(90, fps))
self._fps = fps
def start(self) -> None: def start(self) -> None:
if self._running: if self._running:
return return
@@ -729,11 +741,10 @@ class ColorCycleColorStripStream(ColorStripStream):
logger.info("ColorCycleColorStripStream params updated in-place") logger.info("ColorCycleColorStripStream params updated in-place")
def _animate_loop(self) -> None: def _animate_loop(self) -> None:
"""Background thread: interpolate between colors at ~30 fps. """Background thread: interpolate between colors at target fps.
Uses double-buffered output arrays to avoid per-frame allocations. Uses double-buffered output arrays to avoid per-frame allocations.
""" """
frame_time = 1.0 / 30
_pool_n = 0 _pool_n = 0
_buf_a = _buf_b = None _buf_a = _buf_b = None
_use_a = True _use_a = True
@@ -741,6 +752,7 @@ class ColorCycleColorStripStream(ColorStripStream):
with high_resolution_timer(): with high_resolution_timer():
while self._running: while self._running:
loop_start = time.perf_counter() loop_start = time.perf_counter()
frame_time = 1.0 / self._fps
color_list = self._color_list color_list = self._color_list
speed = self._cycle_speed speed = self._cycle_speed
n = self._led_count n = self._led_count
@@ -776,7 +788,7 @@ class GradientColorStripStream(ColorStripStream):
"""Color strip stream that distributes a gradient across all LEDs. """Color strip stream that distributes a gradient across all LEDs.
Produces a pre-computed (led_count, 3) uint8 array from user-defined Produces a pre-computed (led_count, 3) uint8 array from user-defined
color stops. When animation is enabled a 30 fps background thread applies color stops. When animation is enabled a background thread applies
dynamic effects (breathing, gradient_shift, wave). dynamic effects (breathing, gradient_shift, wave).
LED count auto-sizes from the connected device when led_count == 0 in LED count auto-sizes from the connected device when led_count == 0 in
@@ -788,6 +800,7 @@ class GradientColorStripStream(ColorStripStream):
self._colors_lock = threading.Lock() self._colors_lock = threading.Lock()
self._running = False self._running = False
self._thread: Optional[threading.Thread] = None self._thread: Optional[threading.Thread] = None
self._fps = 30
self._update_from_source(source) self._update_from_source(source)
def _update_from_source(self, source) -> None: def _update_from_source(self, source) -> None:
@@ -816,12 +829,17 @@ class GradientColorStripStream(ColorStripStream):
@property @property
def target_fps(self) -> int: def target_fps(self) -> int:
return 30 return self._fps
@property @property
def led_count(self) -> int: def led_count(self) -> int:
return self._led_count return self._led_count
def set_capture_fps(self, fps: int) -> None:
"""Update animation loop rate. Thread-safe (read atomically by the loop)."""
fps = max(1, min(90, fps))
self._fps = fps
def start(self) -> None: def start(self) -> None:
if self._running: if self._running:
return return
@@ -859,12 +877,11 @@ class GradientColorStripStream(ColorStripStream):
logger.info("GradientColorStripStream params updated in-place") logger.info("GradientColorStripStream params updated in-place")
def _animate_loop(self) -> None: def _animate_loop(self) -> None:
"""Background thread: apply animation effects at ~30 fps when animation is active. """Background thread: apply animation effects at target fps when animation is active.
Uses double-buffered output arrays plus a uint16 scratch buffer for Uses double-buffered output arrays plus a uint16 scratch buffer for
integer-math brightness scaling, avoiding per-frame numpy allocations. integer-math brightness scaling, avoiding per-frame numpy allocations.
""" """
frame_time = 1.0 / 30
_cached_base: Optional[np.ndarray] = None _cached_base: Optional[np.ndarray] = None
_cached_n: int = 0 _cached_n: int = 0
_cached_stops: Optional[list] = None _cached_stops: Optional[list] = None
@@ -876,6 +893,7 @@ class GradientColorStripStream(ColorStripStream):
with high_resolution_timer(): with high_resolution_timer():
while self._running: while self._running:
loop_start = time.perf_counter() loop_start = time.perf_counter()
frame_time = 1.0 / self._fps
anim = self._animation anim = self._animation
if anim and anim.get("enabled"): if anim and anim.get("enabled"):
speed = float(anim.get("speed", 1.0)) speed = float(anim.get("speed", 1.0))