Codebase review: stability, performance, usability, and i18n fixes
Stability: - Fix race condition: set _is_running before create_task in target processors - Await probe task after cancel in wled_target_processor - Replace raw fetch() with fetchWithAuth() across devices, kc-targets, pattern-templates - Add try/catch to showTestTemplateModal in streams.js - Wrap blocking I/O in asyncio.to_thread (picture_targets, system restore) - Fix dashboardStopAll to filter only running targets with ok guard Performance: - Vectorize fire effect spark loop with numpy in effect_stream - Vectorize FFT band binning with cumulative sum in analysis.py - Rewrite pixel_processor with vectorized numpy (accept ndarray or list) - Add httpx.AsyncClient connection pooling with lock in wled_provider - Optimize _send_pixels_http to avoid np.hstack allocation in wled_client - Mutate chart arrays in-place in dashboard, perf-charts, targets - Merge dashboard 2-batch fetch into single Promise.all - Hoist frame_time outside loop in mapped_stream Usability: - Fix health check interval load/save in device settings - Swap confirm modal button classes (No=secondary, Yes=danger) - Add aria-modal to audio/value source editors, fix close button aria-labels - Add modal footer close button to settings modal - Add dedicated calibration LED count validation error keys i18n: - Replace ~50 hardcoded English strings with t() calls across 12 JS files - Add 50 new keys to en.json, ru.json, zh.json - Localize inline toasts in index.html with window.t fallback - Add data-i18n to command palette footer - Add localization policy to CLAUDE.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -99,6 +99,12 @@ class AudioAnalyzer:
|
||||
self._spectrum_buf_right = np.zeros(NUM_BANDS, dtype=np.float32)
|
||||
self._sq_buf = np.empty(chunk_size, dtype=np.float32)
|
||||
|
||||
# Pre-compute band start/end arrays and widths for vectorized binning
|
||||
self._band_starts = np.array([s for s, _ in self._bands], dtype=np.intp)
|
||||
self._band_ends = np.array([e for _, e in self._bands], dtype=np.intp)
|
||||
self._band_widths = (self._band_ends - self._band_starts).astype(np.float32)
|
||||
self._band_widths[self._band_widths == 0] = 1.0 # avoid divide-by-zero
|
||||
|
||||
# Pre-allocated channel buffers for stereo
|
||||
self._left_buf = np.empty(chunk_size, dtype=np.float32)
|
||||
self._right_buf = np.empty(chunk_size, dtype=np.float32)
|
||||
@@ -205,11 +211,15 @@ class AudioAnalyzer:
|
||||
fft_mag = np.abs(np.fft.rfft(self._fft_windowed))
|
||||
fft_mag *= (1.0 / chunk_size)
|
||||
fft_len = len(fft_mag)
|
||||
for b, (s, e) in enumerate(self._bands):
|
||||
if s < fft_len and e <= fft_len:
|
||||
buf[b] = float(np.mean(fft_mag[s:e]))
|
||||
else:
|
||||
buf[b] = 0.0
|
||||
# Vectorized band binning using cumulative sum
|
||||
valid = (self._band_starts < fft_len) & (self._band_ends <= fft_len) & (self._band_ends > 0)
|
||||
buf[:] = 0.0
|
||||
if valid.any():
|
||||
cumsum = np.cumsum(fft_mag)
|
||||
band_sums = cumsum[self._band_ends[valid] - 1] - np.where(
|
||||
self._band_starts[valid] > 0, cumsum[self._band_starts[valid] - 1], 0.0
|
||||
)
|
||||
buf[valid] = band_sums / self._band_widths[valid]
|
||||
spec_max = float(np.max(buf))
|
||||
if spec_max > 1e-6:
|
||||
buf *= (1.0 / spec_max)
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
"""Pixel processing utilities for color correction and manipulation."""
|
||||
|
||||
from typing import List, Tuple
|
||||
from typing import List, Tuple, Union
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
ColorList = Union[List[Tuple[int, int, int]], np.ndarray]
|
||||
|
||||
|
||||
def _as_array(colors: ColorList) -> np.ndarray:
|
||||
"""Convert list-of-tuples to (N,3) uint8 array, or pass through if already ndarray."""
|
||||
if isinstance(colors, np.ndarray):
|
||||
return colors
|
||||
return np.array(colors, dtype=np.uint8)
|
||||
|
||||
|
||||
def smooth_colors(
|
||||
current_colors: List[Tuple[int, int, int]],
|
||||
previous_colors: List[Tuple[int, int, int]],
|
||||
current_colors: ColorList,
|
||||
previous_colors: ColorList,
|
||||
smoothing_factor: float = 0.5,
|
||||
) -> List[Tuple[int, int, int]]:
|
||||
) -> np.ndarray:
|
||||
"""Smooth color transitions between frames.
|
||||
|
||||
Args:
|
||||
@@ -21,96 +30,71 @@ def smooth_colors(
|
||||
smoothing_factor: Smoothing amount (0.0-1.0, where 0=no smoothing, 1=full smoothing)
|
||||
|
||||
Returns:
|
||||
Smoothed colors
|
||||
Smoothed colors as (N,3) uint8 ndarray
|
||||
"""
|
||||
if not current_colors or not previous_colors:
|
||||
return current_colors
|
||||
if not len(current_colors) or not len(previous_colors):
|
||||
return _as_array(current_colors)
|
||||
|
||||
if len(current_colors) != len(previous_colors):
|
||||
logger.warning(
|
||||
f"Color count mismatch: current={len(current_colors)}, "
|
||||
f"previous={len(previous_colors)}. Skipping smoothing."
|
||||
)
|
||||
return current_colors
|
||||
return _as_array(current_colors)
|
||||
|
||||
if smoothing_factor <= 0:
|
||||
return current_colors
|
||||
return _as_array(current_colors)
|
||||
if smoothing_factor >= 1:
|
||||
return previous_colors
|
||||
return _as_array(previous_colors)
|
||||
|
||||
# Convert to numpy arrays
|
||||
current = np.array(current_colors, dtype=np.float32)
|
||||
previous = np.array(previous_colors, dtype=np.float32)
|
||||
|
||||
# Blend between current and previous
|
||||
current = np.asarray(current_colors, dtype=np.float32)
|
||||
previous = np.asarray(previous_colors, dtype=np.float32)
|
||||
smoothed = current * (1 - smoothing_factor) + previous * smoothing_factor
|
||||
|
||||
# Convert back to integers
|
||||
smoothed = np.clip(smoothed, 0, 255).astype(np.uint8)
|
||||
|
||||
return [tuple(color) for color in smoothed]
|
||||
return np.clip(smoothed, 0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
def adjust_brightness_global(
|
||||
colors: List[Tuple[int, int, int]],
|
||||
colors: ColorList,
|
||||
target_brightness: int,
|
||||
) -> List[Tuple[int, int, int]]:
|
||||
) -> np.ndarray:
|
||||
"""Adjust colors to achieve target global brightness.
|
||||
|
||||
Args:
|
||||
colors: List of (R, G, B) tuples
|
||||
colors: List of (R, G, B) tuples or (N,3) ndarray
|
||||
target_brightness: Target brightness (0-255)
|
||||
|
||||
Returns:
|
||||
Adjusted colors
|
||||
Adjusted colors as (N,3) uint8 ndarray
|
||||
"""
|
||||
if not colors or target_brightness == 255:
|
||||
return colors
|
||||
arr = _as_array(colors)
|
||||
if not len(arr) or target_brightness == 255:
|
||||
return arr
|
||||
|
||||
# Calculate scaling factor
|
||||
scale = target_brightness / 255.0
|
||||
|
||||
# Scale all colors
|
||||
scaled = [
|
||||
(
|
||||
int(r * scale),
|
||||
int(g * scale),
|
||||
int(b * scale),
|
||||
)
|
||||
for r, g, b in colors
|
||||
]
|
||||
|
||||
return scaled
|
||||
return (arr.astype(np.float32) * scale).astype(np.uint8)
|
||||
|
||||
|
||||
def limit_brightness(
|
||||
colors: List[Tuple[int, int, int]],
|
||||
colors: ColorList,
|
||||
max_brightness: int = 255,
|
||||
) -> List[Tuple[int, int, int]]:
|
||||
) -> np.ndarray:
|
||||
"""Limit maximum brightness of any color channel.
|
||||
|
||||
Args:
|
||||
colors: List of (R, G, B) tuples
|
||||
colors: List of (R, G, B) tuples or (N,3) ndarray
|
||||
max_brightness: Maximum allowed brightness (0-255)
|
||||
|
||||
Returns:
|
||||
Limited colors
|
||||
Limited colors as (N,3) uint8 ndarray
|
||||
"""
|
||||
if not colors or max_brightness == 255:
|
||||
return colors
|
||||
arr = _as_array(colors)
|
||||
if not len(arr) or max_brightness == 255:
|
||||
return arr
|
||||
|
||||
limited = []
|
||||
for r, g, b in colors:
|
||||
# Find max channel value
|
||||
max_val = max(r, g, b)
|
||||
|
||||
if max_val > max_brightness:
|
||||
# Scale down proportionally
|
||||
scale = max_brightness / max_val
|
||||
r = int(r * scale)
|
||||
g = int(g * scale)
|
||||
b = int(b * scale)
|
||||
|
||||
limited.append((r, g, b))
|
||||
|
||||
return limited
|
||||
arr_f = arr.astype(np.float32)
|
||||
max_vals = np.max(arr_f, axis=1)
|
||||
need_scale = max_vals > max_brightness
|
||||
if need_scale.any():
|
||||
scales = np.where(need_scale, max_brightness / np.maximum(max_vals, 1.0), 1.0)
|
||||
arr_f *= scales[:, np.newaxis]
|
||||
return arr_f.astype(np.uint8)
|
||||
|
||||
@@ -404,8 +404,13 @@ class WLEDClient(LEDClient):
|
||||
"""
|
||||
try:
|
||||
# Build indexed pixel array: [led_index, r, g, b, ...]
|
||||
indices = np.arange(len(pixels), dtype=np.int32).reshape(-1, 1)
|
||||
indexed_pixels = np.hstack([indices, pixels.astype(np.int32)]).ravel().tolist()
|
||||
n = len(pixels)
|
||||
flat = np.empty(n * 4, dtype=np.int32)
|
||||
flat[0::4] = np.arange(n, dtype=np.int32)
|
||||
flat[1::4] = pixels[:, 0]
|
||||
flat[2::4] = pixels[:, 1]
|
||||
flat[3::4] = pixels[:, 2]
|
||||
indexed_pixels = flat.tolist()
|
||||
|
||||
# Build WLED JSON state
|
||||
payload = {
|
||||
|
||||
@@ -24,6 +24,25 @@ DEFAULT_SCAN_TIMEOUT = 3.0
|
||||
class WLEDDeviceProvider(LEDDeviceProvider):
|
||||
"""Provider for WLED LED controllers."""
|
||||
|
||||
def __init__(self):
|
||||
self._http_client: Optional[httpx.AsyncClient] = None
|
||||
self._client_lock = asyncio.Lock()
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
"""Return a shared HTTP client (connection-pooled, thread-safe init)."""
|
||||
if self._http_client is not None and not self._http_client.is_closed:
|
||||
return self._http_client
|
||||
async with self._client_lock:
|
||||
if self._http_client is None or self._http_client.is_closed:
|
||||
self._http_client = httpx.AsyncClient(timeout=5.0)
|
||||
return self._http_client
|
||||
|
||||
async def close(self):
|
||||
"""Close the shared HTTP client."""
|
||||
if self._http_client is not None and not self._http_client.is_closed:
|
||||
await self._http_client.aclose()
|
||||
self._http_client = None
|
||||
|
||||
@property
|
||||
def device_type(self) -> str:
|
||||
return "wled"
|
||||
@@ -158,46 +177,46 @@ class WLEDDeviceProvider(LEDDeviceProvider):
|
||||
|
||||
async def get_brightness(self, url: str) -> int:
|
||||
url = url.rstrip("/")
|
||||
async with httpx.AsyncClient(timeout=5.0) as http_client:
|
||||
resp = await http_client.get(f"{url}/json/state")
|
||||
resp.raise_for_status()
|
||||
state = resp.json()
|
||||
return state.get("bri", 255)
|
||||
client = await self._get_client()
|
||||
resp = await client.get(f"{url}/json/state")
|
||||
resp.raise_for_status()
|
||||
state = resp.json()
|
||||
return state.get("bri", 255)
|
||||
|
||||
async def set_brightness(self, url: str, brightness: int) -> None:
|
||||
url = url.rstrip("/")
|
||||
async with httpx.AsyncClient(timeout=5.0) as http_client:
|
||||
resp = await http_client.post(
|
||||
f"{url}/json/state",
|
||||
json={"bri": brightness},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
client = await self._get_client()
|
||||
resp = await client.post(
|
||||
f"{url}/json/state",
|
||||
json={"bri": brightness},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
async def get_power(self, url: str, **kwargs) -> bool:
|
||||
url = url.rstrip("/")
|
||||
async with httpx.AsyncClient(timeout=5.0) as http_client:
|
||||
resp = await http_client.get(f"{url}/json/state")
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("on", False)
|
||||
client = await self._get_client()
|
||||
resp = await client.get(f"{url}/json/state")
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("on", False)
|
||||
|
||||
async def set_power(self, url: str, on: bool, **kwargs) -> None:
|
||||
url = url.rstrip("/")
|
||||
async with httpx.AsyncClient(timeout=5.0) as http_client:
|
||||
resp = await http_client.post(
|
||||
f"{url}/json/state",
|
||||
json={"on": on},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
client = await self._get_client()
|
||||
resp = await client.post(
|
||||
f"{url}/json/state",
|
||||
json={"on": on},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
async def set_color(self, url: str, color: Tuple[int, int, int], **kwargs) -> None:
|
||||
"""Set WLED to a solid color using the native segment color API."""
|
||||
url = url.rstrip("/")
|
||||
async with httpx.AsyncClient(timeout=5.0) as http_client:
|
||||
resp = await http_client.post(
|
||||
f"{url}/json/state",
|
||||
json={
|
||||
"on": True,
|
||||
"seg": [{"col": [[color[0], color[1], color[2]]], "fx": 0}],
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
client = await self._get_client()
|
||||
resp = await client.post(
|
||||
f"{url}/json/state",
|
||||
json={
|
||||
"on": True,
|
||||
"seg": [{"col": [[color[0], color[1], color[2]]], "fx": 0}],
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
@@ -356,12 +356,17 @@ class EffectColorStripStream(ColorStripStream):
|
||||
new_heat[-1] = heat[-1] * 0.5
|
||||
heat[:] = new_heat
|
||||
|
||||
# Sparks at the bottom
|
||||
# Sparks at the bottom (vectorized)
|
||||
spark_zone = max(1, n // 8)
|
||||
spark_prob = 0.3 * intensity
|
||||
for i in range(spark_zone):
|
||||
if np.random.random() < spark_prob:
|
||||
heat[i] = min(1.0, heat[i] + 0.4 + 0.6 * np.random.random())
|
||||
rng = np.random.random(spark_zone)
|
||||
mask = rng < spark_prob
|
||||
if mask.any():
|
||||
heat[:spark_zone] = np.where(
|
||||
mask,
|
||||
np.minimum(1.0, heat[:spark_zone] + 0.4 + 0.6 * np.random.random(spark_zone)),
|
||||
heat[:spark_zone],
|
||||
)
|
||||
|
||||
# Map heat to palette (pre-allocated scratch)
|
||||
np.multiply(heat, 255, out=self._s_f32_a)
|
||||
|
||||
@@ -174,8 +174,8 @@ class KCTargetProcessor(TargetProcessor):
|
||||
self._latest_colors = None
|
||||
|
||||
# Start processing task
|
||||
self._task = asyncio.create_task(self._processing_loop())
|
||||
self._is_running = True
|
||||
self._task = asyncio.create_task(self._processing_loop())
|
||||
|
||||
logger.info(f"Started KC processing for target {self._target_id}")
|
||||
self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": True})
|
||||
|
||||
@@ -152,10 +152,10 @@ class MappedColorStripStream(ColorStripStream):
|
||||
# ── Processing loop ─────────────────────────────────────────
|
||||
|
||||
def _processing_loop(self) -> None:
|
||||
frame_time = 1.0 / self._fps
|
||||
try:
|
||||
while self._running:
|
||||
loop_start = time.perf_counter()
|
||||
frame_time = 1.0 / self._fps
|
||||
|
||||
try:
|
||||
target_n = self._led_count
|
||||
|
||||
@@ -160,8 +160,8 @@ class WledTargetProcessor(TargetProcessor):
|
||||
|
||||
# Reset metrics and start loop
|
||||
self._metrics = ProcessingMetrics(start_time=datetime.utcnow())
|
||||
self._task = asyncio.create_task(self._processing_loop())
|
||||
self._is_running = True
|
||||
self._task = asyncio.create_task(self._processing_loop())
|
||||
|
||||
logger.info(f"Started processing for target {self._target_id}")
|
||||
self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": True})
|
||||
@@ -889,6 +889,10 @@ class WledTargetProcessor(TargetProcessor):
|
||||
await _probe_client.aclose()
|
||||
if _probe_task is not None and not _probe_task.done():
|
||||
_probe_task.cancel()
|
||||
try:
|
||||
await _probe_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
self._device_reachable = None
|
||||
self._metrics.device_streaming_reachable = None
|
||||
logger.info(f"Processing loop ended for target {self._target_id}")
|
||||
|
||||
Reference in New Issue
Block a user