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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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": "Цвета:",
|
||||
|
||||
Reference in New Issue
Block a user