Add animation effects + double-buffered FPS optimization

- Add 5 new animation effects (strobe, sparkle, pulse, candle, rainbow
  fade) to both static and gradient color strip streams
- Fix FPS drops (30→25) by using 5ms re-poll on frame skip instead of
  full frame_time, preventing synchronization misses between animation
  thread and processing loop
- Double-buffer animation output arrays to eliminate per-frame numpy
  allocations and reduce GC pressure
- Use uint16 integer math for gradient brightness scaling instead of
  float32 intermediates
- Update animation type dropdowns and locale strings (en + ru)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 01:57:43 +03:00
parent 84f063eee9
commit 55a9662234
5 changed files with 220 additions and 25 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -57,13 +57,23 @@ export function onCSSTypeChange() {
if (type === 'static') {
animSection.style.display = '';
animTypeSelect.innerHTML =
`<option value="breathing">${t('color_strip.animation.type.breathing')}</option>`;
`<option value="breathing">${t('color_strip.animation.type.breathing')}</option>` +
`<option value="strobe">${t('color_strip.animation.type.strobe')}</option>` +
`<option value="sparkle">${t('color_strip.animation.type.sparkle')}</option>` +
`<option value="pulse">${t('color_strip.animation.type.pulse')}</option>` +
`<option value="candle">${t('color_strip.animation.type.candle')}</option>` +
`<option value="rainbow_fade">${t('color_strip.animation.type.rainbow_fade')}</option>`;
} else if (type === 'gradient') {
animSection.style.display = '';
animTypeSelect.innerHTML =
`<option value="breathing">${t('color_strip.animation.type.breathing')}</option>` +
`<option value="gradient_shift">${t('color_strip.animation.type.gradient_shift')}</option>` +
`<option value="wave">${t('color_strip.animation.type.wave')}</option>`;
`<option value="wave">${t('color_strip.animation.type.wave')}</option>` +
`<option value="strobe">${t('color_strip.animation.type.strobe')}</option>` +
`<option value="sparkle">${t('color_strip.animation.type.sparkle')}</option>` +
`<option value="pulse">${t('color_strip.animation.type.pulse')}</option>` +
`<option value="candle">${t('color_strip.animation.type.candle')}</option>` +
`<option value="rainbow_fade">${t('color_strip.animation.type.rainbow_fade')}</option>`;
} else {
animSection.style.display = 'none';
}

View File

@@ -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:",

View File

@@ -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": "Цвета:",