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 53e58aa..9f61984 100644
--- a/server/src/wled_controller/core/processing/color_strip_stream.py
+++ b/server/src/wled_controller/core/processing/color_strip_stream.py
@@ -10,6 +10,7 @@ processing — border extraction, pixel mapping, color correction — runs only
even when multiple devices share the same source configuration.
"""
+import colorsys
import math
import threading
import time
@@ -537,8 +538,18 @@ class StaticColorStripStream(ColorStripStream):
logger.info("StaticColorStripStream params updated in-place")
def _animate_loop(self) -> None:
- """Background thread: compute animated colors at ~30 fps when animation is active."""
+ """Background thread: compute animated colors at ~30 fps when animation is active.
+
+ Uses double-buffered output arrays (buf_a / buf_b) to avoid per-frame
+ numpy allocations while preserving the identity check used by the
+ processing loop (``colors is prev_colors``).
+ """
frame_time = 1.0 / 30
+ # Double-buffer pool — re-allocated only when LED count changes
+ _pool_n = 0
+ _buf_a = _buf_b = None
+ _use_a = True
+
with high_resolution_timer():
while self._running:
loop_start = time.perf_counter()
@@ -548,13 +559,72 @@ class StaticColorStripStream(ColorStripStream):
atype = anim.get("type", "breathing")
t = loop_start
n = self._led_count
+
+ if n != _pool_n:
+ _pool_n = n
+ _buf_a = np.empty((n, 3), dtype=np.uint8)
+ _buf_b = np.empty((n, 3), dtype=np.uint8)
+
+ buf = _buf_a if _use_a else _buf_b
+ _use_a = not _use_a
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))
+ r, g, b = self._source_color
+ buf[:] = (min(255, int(r * factor)), min(255, int(g * factor)), min(255, int(b * factor)))
+ colors = buf
+
+ elif atype == "strobe":
+ # Square wave: on for half the period, off for the other half.
+ # speed=1.0 → 2 flashes/sec (one full on/off cycle per 0.5s)
+ if math.sin(2 * math.pi * speed * t * 2.0) >= 0:
+ buf[:] = self._source_color
+ else:
+ buf[:] = 0
+ colors = buf
+
+ elif atype == "sparkle":
+ # Random LEDs flash white while the rest stay the base color
+ buf[:] = self._source_color
+ density = min(0.5, 0.1 * speed)
+ mask = np.random.random(n) < density
+ buf[mask] = (255, 255, 255)
+ colors = buf
+
+ elif atype == "pulse":
+ # Sharp attack, slow exponential decay — heartbeat-like
+ # speed=1.0 → ~1 pulse per second
+ phase = (speed * t * 1.0) % 1.0
+ if phase < 0.1:
+ factor = phase / 0.1
+ else:
+ factor = math.exp(-5.0 * (phase - 0.1))
+ r, g, b = self._source_color
+ buf[:] = (min(255, int(r * factor)), min(255, int(g * factor)), min(255, int(b * factor)))
+ colors = buf
+
+ elif atype == "candle":
+ # Random brightness fluctuations simulating a candle flame
+ base_factor = 0.75
+ flicker = 0.25 * math.sin(2 * math.pi * speed * t * 3.7)
+ flicker += 0.15 * math.sin(2 * math.pi * speed * t * 7.3)
+ flicker += 0.10 * (np.random.random() - 0.5)
+ factor = max(0.2, min(1.0, base_factor + flicker))
+ r, g, b = self._source_color
+ buf[:] = (min(255, int(r * factor)), min(255, int(g * factor)), min(255, int(b * factor)))
+ colors = buf
+
+ elif atype == "rainbow_fade":
+ # Shift hue continuously from the base color
+ r, g, b = self._source_color
+ h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0)
+ # speed=1.0 → one full hue rotation every ~10s
+ h_shift = (speed * t * 0.1) % 1.0
+ new_h = (h + h_shift) % 1.0
+ nr, ng, nb = colorsys.hsv_to_rgb(new_h, max(s, 0.5), max(v, 0.3))
+ buf[:] = (int(nr * 255), int(ng * 255), int(nb * 255))
+ colors = buf
if colors is not None:
with self._colors_lock:
@@ -652,8 +722,15 @@ class ColorCycleColorStripStream(ColorStripStream):
logger.info("ColorCycleColorStripStream params updated in-place")
def _animate_loop(self) -> None:
- """Background thread: interpolate between colors at ~30 fps."""
+ """Background thread: interpolate between colors at ~30 fps.
+
+ Uses double-buffered output arrays to avoid per-frame allocations.
+ """
frame_time = 1.0 / 30
+ _pool_n = 0
+ _buf_a = _buf_b = None
+ _use_a = True
+
with high_resolution_timer():
while self._running:
loop_start = time.perf_counter()
@@ -662,17 +739,28 @@ class ColorCycleColorStripStream(ColorStripStream):
n = self._led_count
num = len(color_list)
if num >= 2:
+ if n != _pool_n:
+ _pool_n = n
+ _buf_a = np.empty((n, 3), dtype=np.uint8)
+ _buf_b = np.empty((n, 3), dtype=np.uint8)
+
+ buf = _buf_a if _use_a else _buf_b
+ _use_a = not _use_a
+
# 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))
+ t_i = seg - int(seg)
+ c1 = color_list[idx]
+ c2 = color_list[(idx + 1) % num]
+ buf[:] = (
+ min(255, int(c1[0] + (c2[0] - c1[0]) * t_i)),
+ min(255, int(c1[1] + (c2[1] - c1[1]) * t_i)),
+ min(255, int(c1[2] + (c2[2] - c1[2]) * t_i)),
+ )
with self._colors_lock:
- self._colors = led_colors
+ self._colors = buf
elapsed = time.perf_counter() - loop_start
time.sleep(max(frame_time - elapsed, 0.001))
@@ -764,11 +852,20 @@ class GradientColorStripStream(ColorStripStream):
logger.info("GradientColorStripStream params updated in-place")
def _animate_loop(self) -> None:
- """Background thread: apply animation effects at ~30 fps when animation is active."""
+ """Background thread: apply animation effects at ~30 fps when animation is active.
+
+ Uses double-buffered output arrays plus a uint16 scratch buffer for
+ integer-math brightness scaling, avoiding per-frame numpy allocations.
+ """
frame_time = 1.0 / 30
_cached_base: Optional[np.ndarray] = None
_cached_n: int = 0
_cached_stops: Optional[list] = None
+ # Double-buffer pool + uint16 scratch for brightness math
+ _pool_n = 0
+ _buf_a = _buf_b = _scratch_u16 = None
+ _use_a = True
+
with high_resolution_timer():
while self._running:
loop_start = time.perf_counter()
@@ -788,25 +885,98 @@ class GradientColorStripStream(ColorStripStream):
_cached_stops = stops
base = _cached_base
+ # Re-allocate pool only when LED count changes
+ if n != _pool_n:
+ _pool_n = n
+ _buf_a = np.empty((n, 3), dtype=np.uint8)
+ _buf_b = np.empty((n, 3), dtype=np.uint8)
+ _scratch_u16 = np.empty((n, 3), dtype=np.uint16)
+
+ buf = _buf_a if _use_a else _buf_b
+ _use_a = not _use_a
+
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)
+ int_f = max(0, min(256, int(0.5 * (1 + math.sin(2 * math.pi * speed * t * 0.5)) * 256)))
+ np.copyto(_scratch_u16, base)
+ _scratch_u16 *= int_f
+ _scratch_u16 >>= 8
+ np.copyto(buf, _scratch_u16, casting='unsafe')
+ colors = buf
elif atype == "gradient_shift":
shift = int(speed * t * 10) % max(n, 1)
- colors = np.roll(base, shift, axis=0)
+ if shift > 0:
+ buf[:n - shift] = base[shift:]
+ buf[n - shift:] = base[:shift]
+ else:
+ np.copyto(buf, base)
+ colors = buf
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
+ 2 * math.pi * i_arr / n - 2 * math.pi * speed * t * 0.25
))
- colors = np.clip(
- base.astype(np.float32) * factor[:, None], 0, 255
- ).astype(np.uint8)
+ int_factors = np.clip(factor * 256, 0, 256).astype(np.uint16)
+ np.copyto(_scratch_u16, base)
+ _scratch_u16 *= int_factors[:, None]
+ _scratch_u16 >>= 8
+ np.copyto(buf, _scratch_u16, casting='unsafe')
+ colors = buf
else:
- colors = base
+ np.copyto(buf, base)
+ colors = buf
+
+ elif atype == "strobe":
+ if math.sin(2 * math.pi * speed * t * 2.0) >= 0:
+ np.copyto(buf, base)
+ else:
+ buf[:] = 0
+ colors = buf
+
+ elif atype == "sparkle":
+ np.copyto(buf, base)
+ density = min(0.5, 0.1 * speed)
+ mask = np.random.random(n) < density
+ buf[mask] = (255, 255, 255)
+ colors = buf
+
+ elif atype == "pulse":
+ phase = (speed * t * 1.0) % 1.0
+ if phase < 0.1:
+ factor = phase / 0.1
+ else:
+ factor = math.exp(-5.0 * (phase - 0.1))
+ int_f = max(0, min(256, int(factor * 256)))
+ np.copyto(_scratch_u16, base)
+ _scratch_u16 *= int_f
+ _scratch_u16 >>= 8
+ np.copyto(buf, _scratch_u16, casting='unsafe')
+ colors = buf
+
+ elif atype == "candle":
+ base_factor = 0.75
+ flicker = 0.25 * math.sin(2 * math.pi * speed * t * 3.7)
+ flicker += 0.15 * math.sin(2 * math.pi * speed * t * 7.3)
+ flicker += 0.10 * (np.random.random() - 0.5)
+ factor = max(0.2, min(1.0, base_factor + flicker))
+ int_f = int(factor * 256)
+ np.copyto(_scratch_u16, base)
+ _scratch_u16 *= int_f
+ _scratch_u16 >>= 8
+ np.copyto(buf, _scratch_u16, casting='unsafe')
+ colors = buf
+
+ elif atype == "rainbow_fade":
+ h_shift = (speed * t * 0.1) % 1.0
+ for i in range(n):
+ r, g, b = base[i]
+ h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0)
+ new_h = (h + h_shift) % 1.0
+ nr, ng, nb = colorsys.hsv_to_rgb(new_h, max(s, 0.5), max(v, 0.3))
+ buf[i] = (int(nr * 255), int(ng * 255), int(nb * 255))
+ colors = buf
if colors is not None:
with self._colors_lock:
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 9696f97..162468e 100644
--- a/server/src/wled_controller/core/processing/wled_target_processor.py
+++ b/server/src/wled_controller/core/processing/wled_target_processor.py
@@ -370,6 +370,11 @@ class WledTargetProcessor(TargetProcessor):
last_send_time = 0.0
prev_frame_time_stamp = time.perf_counter()
loop = asyncio.get_running_loop()
+ # Short re-poll interval when the animation thread hasn't produced a new
+ # frame yet. The animation thread and this loop both target the same FPS
+ # but are unsynchronised; without a short re-poll the loop can miss a
+ # frame and wait a full frame_time, periodically halving the send rate.
+ SKIP_REPOLL = 0.005 # 5 ms
logger.info(
f"Processing loop started for target {self._target_id} "
@@ -419,7 +424,7 @@ class WledTargetProcessor(TargetProcessor):
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)
+ await asyncio.sleep(SKIP_REPOLL)
continue
prev_colors = colors
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 81e1478..ec9b1c0 100644
--- a/server/src/wled_controller/static/js/features/color-strips.js
+++ b/server/src/wled_controller/static/js/features/color-strips.js
@@ -57,13 +57,23 @@ export function onCSSTypeChange() {
if (type === 'static') {
animSection.style.display = '';
animTypeSelect.innerHTML =
- ``;
+ `` +
+ `` +
+ `` +
+ `` +
+ `` +
+ ``;
} else if (type === 'gradient') {
animSection.style.display = '';
animTypeSelect.innerHTML =
`` +
`` +
- ``;
+ `` +
+ `` +
+ `` +
+ `` +
+ `` +
+ ``;
} else {
animSection.style.display = 'none';
}
diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json
index 3f95519..49b338a 100644
--- a/server/src/wled_controller/static/locales/en.json
+++ b/server/src/wled_controller/static/locales/en.json
@@ -596,11 +596,16 @@
"color_strip.animation.enabled": "Enable Animation:",
"color_strip.animation.enabled.hint": "Enables procedural animation. The LEDs will update at 30 fps driven by the selected effect.",
"color_strip.animation.type": "Effect:",
- "color_strip.animation.type.hint": "The animation effect to apply. Breathing works for both static and gradient sources; Gradient Shift and Wave work for gradient sources only.",
+ "color_strip.animation.type.hint": "The animation effect to apply. Breathing, Strobe, Sparkle, Pulse, Candle, and Rainbow Fade work for both static and gradient sources; Gradient Shift and Wave are gradient-only.",
"color_strip.animation.type.breathing": "Breathing",
"color_strip.animation.type.color_cycle": "Color Cycle",
"color_strip.animation.type.gradient_shift": "Gradient Shift",
"color_strip.animation.type.wave": "Wave",
+ "color_strip.animation.type.strobe": "Strobe",
+ "color_strip.animation.type.sparkle": "Sparkle",
+ "color_strip.animation.type.pulse": "Pulse",
+ "color_strip.animation.type.candle": "Candle",
+ "color_strip.animation.type.rainbow_fade": "Rainbow Fade",
"color_strip.animation.speed": "Speed:",
"color_strip.animation.speed.hint": "Animation speed multiplier. 1.0 ≈ one cycle per second for Breathing; higher values cycle faster.",
"color_strip.color_cycle.colors": "Colors:",
diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json
index bd9cd4a..e953312 100644
--- a/server/src/wled_controller/static/locales/ru.json
+++ b/server/src/wled_controller/static/locales/ru.json
@@ -596,11 +596,16 @@
"color_strip.animation.enabled": "Включить анимацию:",
"color_strip.animation.enabled.hint": "Включает процедурную анимацию. Светодиоды обновляются со скоростью 30 кадров в секунду по выбранному эффекту.",
"color_strip.animation.type": "Эффект:",
- "color_strip.animation.type.hint": "Эффект анимации. Дыхание работает для статичного цвета и градиента; сдвиг градиента и волна — только для градиентных источников.",
+ "color_strip.animation.type.hint": "Эффект анимации. Дыхание, стробоскоп, искры, пульс, свеча и радужный перелив работают для статического цвета и градиента; сдвиг градиента и волна — только для градиентов.",
"color_strip.animation.type.breathing": "Дыхание",
"color_strip.animation.type.color_cycle": "Смена цвета",
"color_strip.animation.type.gradient_shift": "Сдвиг градиента",
"color_strip.animation.type.wave": "Волна",
+ "color_strip.animation.type.strobe": "Стробоскоп",
+ "color_strip.animation.type.sparkle": "Искры",
+ "color_strip.animation.type.pulse": "Пульс",
+ "color_strip.animation.type.candle": "Свеча",
+ "color_strip.animation.type.rainbow_fade": "Радужный перелив",
"color_strip.animation.speed": "Скорость:",
"color_strip.animation.speed.hint": "Множитель скорости анимации. 1.0 ≈ один цикл в секунду для дыхания; большие значения ускоряют анимацию.",
"color_strip.color_cycle.colors": "Цвета:",