Add value sources for dynamic brightness control on LED targets

Introduces a new Value Source entity that produces a scalar float (0.0-1.0)
for dynamic brightness modulation. Three subtypes: Static (constant),
Animated (sine/triangle/square/sawtooth waveform), and Audio-reactive
(RMS/peak/beat from mono audio source). Value sources can be optionally
attached to LED targets to control brightness each frame.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 12:19:40 +03:00
parent 27720e51aa
commit ef474fe275
26 changed files with 1704 additions and 14 deletions

View File

@@ -16,6 +16,7 @@ from wled_controller.core.devices.led_client import (
from wled_controller.core.audio.audio_capture import AudioCaptureManager
from wled_controller.core.processing.live_stream_manager import LiveStreamManager
from wled_controller.core.processing.color_strip_stream_manager import ColorStripStreamManager
from wled_controller.core.processing.value_stream import ValueStreamManager
from wled_controller.core.capture.screen_overlay import OverlayManager
from wled_controller.core.processing.target_processor import (
DeviceInfo,
@@ -64,7 +65,7 @@ class ProcessorManager:
Targets are registered for processing via polymorphic TargetProcessor subclasses.
"""
def __init__(self, picture_source_store=None, capture_template_store=None, pp_template_store=None, pattern_template_store=None, device_store=None, color_strip_store=None, audio_source_store=None):
def __init__(self, picture_source_store=None, capture_template_store=None, pp_template_store=None, pattern_template_store=None, device_store=None, color_strip_store=None, audio_source_store=None, value_source_store=None):
"""Initialize processor manager."""
self._devices: Dict[str, DeviceState] = {}
self._processors: Dict[str, TargetProcessor] = {}
@@ -78,6 +79,7 @@ class ProcessorManager:
self._device_store = device_store
self._color_strip_store = color_strip_store
self._audio_source_store = audio_source_store
self._value_source_store = value_source_store
self._live_stream_manager = LiveStreamManager(
picture_source_store, capture_template_store, pp_template_store
)
@@ -88,6 +90,11 @@ class ProcessorManager:
audio_capture_manager=self._audio_capture_manager,
audio_source_store=audio_source_store,
)
self._value_stream_manager = ValueStreamManager(
value_source_store=value_source_store,
audio_capture_manager=self._audio_capture_manager,
audio_source_store=audio_source_store,
) if value_source_store else None
self._overlay_manager = OverlayManager()
self._event_queues: List[asyncio.Queue] = []
logger.info("Processor manager initialized")
@@ -105,6 +112,7 @@ class ProcessorManager:
pattern_template_store=self._pattern_template_store,
device_store=self._device_store,
color_strip_stream_manager=self._color_strip_stream_manager,
value_stream_manager=self._value_stream_manager,
fire_event=self._fire_event,
get_device_info=self._get_device_info,
)
@@ -276,6 +284,7 @@ class ProcessorManager:
fps: int = 30,
keepalive_interval: float = 1.0,
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
brightness_value_source_id: str = "",
):
"""Register a WLED target processor."""
if target_id in self._processors:
@@ -290,6 +299,7 @@ class ProcessorManager:
fps=fps,
keepalive_interval=keepalive_interval,
state_check_interval=state_check_interval,
brightness_value_source_id=brightness_value_source_id,
ctx=self._build_context(),
)
self._processors[target_id] = proc
@@ -347,6 +357,17 @@ class ProcessorManager:
raise ValueError(f"Device {device_id} not registered")
proc.update_device(device_id)
def update_target_brightness_vs(self, target_id: str, vs_id: str):
"""Update the brightness value source for a WLED target."""
proc = self._get_processor(target_id)
if hasattr(proc, "update_brightness_value_source"):
proc.update_brightness_value_source(vs_id)
def update_value_source(self, vs_id: str):
"""Hot-update all running value streams for a given source."""
if self._value_stream_manager:
self._value_stream_manager.update_source(vs_id)
async def start_processing(self, target_id: str):
"""Start processing for a target (any type)."""
proc = self._get_processor(target_id)
@@ -719,6 +740,10 @@ class ProcessorManager:
# Safety net: release all color strip streams
self._color_strip_stream_manager.release_all()
# Safety net: release all value streams
if self._value_stream_manager:
self._value_stream_manager.release_all()
# Safety net: release any remaining managed live streams
self._live_stream_manager.release_all()

View File

@@ -19,6 +19,7 @@ from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple
if TYPE_CHECKING:
from wled_controller.core.processing.color_strip_stream_manager import ColorStripStreamManager
from wled_controller.core.processing.live_stream_manager import LiveStreamManager
from wled_controller.core.processing.value_stream import ValueStreamManager
from wled_controller.core.capture.screen_overlay import OverlayManager
from wled_controller.storage import DeviceStore
from wled_controller.storage.picture_source_store import PictureSourceStore
@@ -85,6 +86,7 @@ class TargetContext:
pattern_template_store: Optional["PatternTemplateStore"] = None
device_store: Optional["DeviceStore"] = None
color_strip_stream_manager: Optional["ColorStripStreamManager"] = None
value_stream_manager: Optional["ValueStreamManager"] = None
fire_event: Callable[[dict], None] = lambda e: None
get_device_info: Callable[[str], Optional[DeviceInfo]] = lambda _: None

View File

@@ -0,0 +1,389 @@
"""Value stream — runtime scalar signal generators.
A ValueStream wraps a ValueSource config and computes a float (0.01.0)
on demand via ``get_value()``. Three concrete types:
StaticValueStream — returns a constant
AnimatedValueStream — evaluates a periodic waveform (sine/triangle/square/sawtooth)
AudioValueStream — polls audio analysis for RMS/peak/beat, applies
sensitivity and temporal smoothing
ValueStreams are cheap (trivial math or single poll), so they compute inline
in the caller's processing loop — no background threads required.
ValueStreamManager owns all running ValueStreams, keyed by
``{vs_id}:{consumer_id}``. Processors call acquire/release.
"""
from __future__ import annotations
import math
import time
from typing import TYPE_CHECKING, Dict, Optional
from wled_controller.utils import get_logger
if TYPE_CHECKING:
from wled_controller.core.audio.audio_capture import AudioCaptureManager
from wled_controller.storage.audio_source_store import AudioSourceStore
from wled_controller.storage.value_source import ValueSource
from wled_controller.storage.value_source_store import ValueSourceStore
logger = get_logger(__name__)
# ---------------------------------------------------------------------------
# Base class
# ---------------------------------------------------------------------------
class ValueStream:
"""Abstract base for runtime value streams."""
def get_value(self) -> float:
"""Return current scalar value (0.01.0)."""
return 1.0
def start(self) -> None:
"""Acquire resources (if any)."""
def stop(self) -> None:
"""Release resources (if any)."""
def update_source(self, source: "ValueSource") -> None:
"""Hot-update parameters from a modified ValueSource config."""
# ---------------------------------------------------------------------------
# Static
# ---------------------------------------------------------------------------
class StaticValueStream(ValueStream):
"""Returns a constant float."""
def __init__(self, value: float = 1.0):
self._value = max(0.0, min(1.0, value))
def get_value(self) -> float:
return self._value
def update_source(self, source: "ValueSource") -> None:
from wled_controller.storage.value_source import StaticValueSource
if isinstance(source, StaticValueSource):
self._value = max(0.0, min(1.0, source.value))
# ---------------------------------------------------------------------------
# Animated
# ---------------------------------------------------------------------------
_TWO_PI = 2.0 * math.pi
class AnimatedValueStream(ValueStream):
"""Evaluates a periodic waveform from wall-clock time.
Waveforms: sine, triangle, square, sawtooth.
Speed is in cycles per minute (cpm). Output is mapped to
[min_value, max_value].
"""
def __init__(
self,
waveform: str = "sine",
speed: float = 10.0,
min_value: float = 0.0,
max_value: float = 1.0,
):
self._waveform = waveform
self._speed = speed
self._min = min_value
self._max = max_value
self._start_time = time.perf_counter()
def get_value(self) -> float:
elapsed = time.perf_counter() - self._start_time
# phase in [0, 1)
cycles_per_sec = self._speed / 60.0
phase = (elapsed * cycles_per_sec) % 1.0
# Raw waveform value in [0, 1]
wf = self._waveform
if wf == "sine":
raw = 0.5 + 0.5 * math.sin(_TWO_PI * phase)
elif wf == "triangle":
raw = 1.0 - abs(2.0 * phase - 1.0)
elif wf == "square":
raw = 1.0 if phase < 0.5 else 0.0
elif wf == "sawtooth":
raw = phase
else:
raw = 0.5 + 0.5 * math.sin(_TWO_PI * phase)
# Map to [min, max]
return self._min + raw * (self._max - self._min)
def update_source(self, source: "ValueSource") -> None:
from wled_controller.storage.value_source import AnimatedValueSource
if isinstance(source, AnimatedValueSource):
self._waveform = source.waveform
self._speed = source.speed
self._min = source.min_value
self._max = source.max_value
# ---------------------------------------------------------------------------
# Audio
# ---------------------------------------------------------------------------
class AudioValueStream(ValueStream):
"""Polls audio analysis for a scalar value.
Modes:
rms — root-mean-square level (overall volume)
peak — peak amplitude
beat — 1.0 on beat, decays toward 0.0 between beats
"""
def __init__(
self,
audio_source_id: str,
mode: str = "rms",
sensitivity: float = 1.0,
smoothing: float = 0.3,
audio_capture_manager: Optional["AudioCaptureManager"] = None,
audio_source_store: Optional["AudioSourceStore"] = None,
):
self._audio_source_id = audio_source_id
self._mode = mode
self._sensitivity = sensitivity
self._smoothing = smoothing
self._audio_capture_manager = audio_capture_manager
self._audio_source_store = audio_source_store
# Resolved audio device params
self._audio_device_index = -1
self._audio_loopback = True
self._audio_channel = "mono"
self._audio_stream = None
self._prev_value = 0.0
self._beat_brightness = 0.0
self._resolve_audio_source()
def _resolve_audio_source(self) -> None:
"""Resolve mono audio source to device index / channel."""
if self._audio_source_id and self._audio_source_store:
try:
device_index, is_loopback, channel = (
self._audio_source_store.resolve_mono_source(self._audio_source_id)
)
self._audio_device_index = device_index
self._audio_loopback = is_loopback
self._audio_channel = channel
except ValueError as e:
logger.warning(f"Failed to resolve audio source {self._audio_source_id}: {e}")
def start(self) -> None:
if self._audio_capture_manager is None:
return
self._audio_stream = self._audio_capture_manager.acquire(
self._audio_device_index, self._audio_loopback
)
logger.info(
f"AudioValueStream started (mode={self._mode}, "
f"device={self._audio_device_index}, loopback={self._audio_loopback})"
)
def stop(self) -> None:
if self._audio_stream is not None and self._audio_capture_manager is not None:
self._audio_capture_manager.release(self._audio_device_index, self._audio_loopback)
self._audio_stream = None
self._prev_value = 0.0
self._beat_brightness = 0.0
def get_value(self) -> float:
if self._audio_stream is None:
return 0.0
analysis = self._audio_stream.get_latest_analysis()
if analysis is None:
return self._prev_value
raw = self._extract_raw(analysis)
raw = min(1.0, raw * self._sensitivity)
# Temporal smoothing
smoothed = self._smoothing * self._prev_value + (1.0 - self._smoothing) * raw
self._prev_value = smoothed
return max(0.0, min(1.0, smoothed))
def _extract_raw(self, analysis) -> float:
"""Extract raw scalar from audio analysis based on mode."""
if self._mode == "peak":
return self._pick_peak(analysis)
if self._mode == "beat":
return self._compute_beat(analysis)
# Default: rms
return self._pick_rms(analysis)
def _pick_rms(self, analysis) -> float:
if self._audio_channel == "left":
return getattr(analysis, "left_rms", 0.0)
if self._audio_channel == "right":
return getattr(analysis, "right_rms", 0.0)
return getattr(analysis, "rms", 0.0)
def _pick_peak(self, analysis) -> float:
if self._audio_channel == "left":
return getattr(analysis, "left_peak", 0.0)
if self._audio_channel == "right":
return getattr(analysis, "right_peak", 0.0)
return getattr(analysis, "peak", 0.0)
def _compute_beat(self, analysis) -> float:
if getattr(analysis, "beat", False):
self._beat_brightness = 1.0
else:
decay = 0.05 + 0.15 * (1.0 / max(self._sensitivity, 0.1))
self._beat_brightness = max(0.0, self._beat_brightness - decay)
return self._beat_brightness
def update_source(self, source: "ValueSource") -> None:
from wled_controller.storage.value_source import AudioValueSource
if not isinstance(source, AudioValueSource):
return
old_source_id = self._audio_source_id
self._audio_source_id = source.audio_source_id
self._mode = source.mode
self._sensitivity = source.sensitivity
self._smoothing = source.smoothing
# If audio source changed, re-resolve and swap capture stream
if source.audio_source_id != old_source_id:
old_device = self._audio_device_index
old_loopback = self._audio_loopback
self._resolve_audio_source()
if self._audio_stream is not None and self._audio_capture_manager is not None:
self._audio_capture_manager.release(old_device, old_loopback)
self._audio_stream = self._audio_capture_manager.acquire(
self._audio_device_index, self._audio_loopback
)
logger.info(
f"AudioValueStream swapped audio device: "
f"{old_device}:{old_loopback}"
f"{self._audio_device_index}:{self._audio_loopback}"
)
# ---------------------------------------------------------------------------
# Manager
# ---------------------------------------------------------------------------
def _make_key(vs_id: str, consumer_id: str) -> str:
return f"{vs_id}:{consumer_id}"
class ValueStreamManager:
"""Owns running ValueStream instances, keyed by ``vs_id:consumer_id``.
Each consumer (target processor) gets its own stream instance —
no sharing or ref-counting needed since streams are cheap.
"""
def __init__(
self,
value_source_store: "ValueSourceStore",
audio_capture_manager: Optional["AudioCaptureManager"] = None,
audio_source_store: Optional["AudioSourceStore"] = None,
):
self._value_source_store = value_source_store
self._audio_capture_manager = audio_capture_manager
self._audio_source_store = audio_source_store
self._streams: Dict[str, ValueStream] = {}
def acquire(self, vs_id: str, consumer_id: str) -> ValueStream:
"""Create and start a ValueStream for the given ValueSource.
Args:
vs_id: ID of the ValueSource config
consumer_id: Unique consumer identifier (target_id)
Returns:
Running ValueStream instance
"""
key = _make_key(vs_id, consumer_id)
if key in self._streams:
return self._streams[key]
source = self._value_source_store.get_source(vs_id)
stream = self._create_stream(source)
stream.start()
self._streams[key] = stream
logger.info(f"Acquired value stream {key} (type={source.source_type})")
return stream
def release(self, vs_id: str, consumer_id: str) -> None:
"""Stop and remove a ValueStream."""
key = _make_key(vs_id, consumer_id)
stream = self._streams.pop(key, None)
if stream:
stream.stop()
logger.info(f"Released value stream {key}")
def update_source(self, vs_id: str) -> None:
"""Hot-update all running streams that use the given ValueSource."""
try:
source = self._value_source_store.get_source(vs_id)
except ValueError:
return
prefix = f"{vs_id}:"
for key, stream in self._streams.items():
if key.startswith(prefix):
stream.update_source(source)
logger.debug(f"Updated running value streams for source {vs_id}")
def release_all(self) -> None:
"""Stop and remove all managed streams. Called on shutdown."""
for key, stream in self._streams.items():
try:
stream.stop()
except Exception as e:
logger.error(f"Error stopping value stream {key}: {e}")
self._streams.clear()
logger.info("Released all value streams")
def _create_stream(self, source: "ValueSource") -> ValueStream:
"""Factory: create the appropriate ValueStream for a ValueSource."""
from wled_controller.storage.value_source import (
AnimatedValueSource,
AudioValueSource,
StaticValueSource,
)
if isinstance(source, StaticValueSource):
return StaticValueStream(value=source.value)
if isinstance(source, AnimatedValueSource):
return AnimatedValueStream(
waveform=source.waveform,
speed=source.speed,
min_value=source.min_value,
max_value=source.max_value,
)
if isinstance(source, AudioValueSource):
return AudioValueStream(
audio_source_id=source.audio_source_id,
mode=source.mode,
sensitivity=source.sensitivity,
smoothing=source.smoothing,
audio_capture_manager=self._audio_capture_manager,
audio_source_store=self._audio_source_store,
)
# Fallback
return StaticValueStream(value=1.0)

View File

@@ -35,6 +35,7 @@ class WledTargetProcessor(TargetProcessor):
fps: int = 30,
keepalive_interval: float = 1.0,
state_check_interval: int = 30,
brightness_value_source_id: str = "",
ctx: TargetContext = None,
):
super().__init__(target_id, ctx)
@@ -43,10 +44,12 @@ class WledTargetProcessor(TargetProcessor):
self._keepalive_interval = keepalive_interval
self._state_check_interval = state_check_interval
self._css_id = color_strip_source_id
self._brightness_vs_id = brightness_value_source_id
# Runtime state (populated on start)
self._led_client: Optional[LEDClient] = None
self._css_stream: Optional[object] = None # active stream reference
self._value_stream = None # active brightness value stream
self._device_state_before: Optional[dict] = None
self._overlay_active = False
self._needs_keepalive = True
@@ -122,6 +125,16 @@ class WledTargetProcessor(TargetProcessor):
self._led_client = None
raise RuntimeError(f"Failed to acquire CSS stream: {e}")
# Acquire value stream for brightness modulation (if configured)
if self._brightness_vs_id and self._ctx.value_stream_manager:
try:
self._value_stream = self._ctx.value_stream_manager.acquire(
self._brightness_vs_id, self._target_id
)
except Exception as e:
logger.warning(f"Failed to acquire value stream {self._brightness_vs_id}: {e}")
self._value_stream = None
# Reset metrics and start loop
self._metrics = ProcessingMetrics(start_time=datetime.utcnow())
self._task = asyncio.create_task(self._processing_loop())
@@ -167,6 +180,14 @@ class WledTargetProcessor(TargetProcessor):
logger.warning(f"Error releasing CSS stream {self._css_id} for {self._target_id}: {e}")
self._css_stream = None
# Release value stream
if self._value_stream is not None and self._ctx.value_stream_manager:
try:
self._ctx.value_stream_manager.release(self._brightness_vs_id, self._target_id)
except Exception as e:
logger.warning(f"Error releasing value stream: {e}")
self._value_stream = None
logger.info(f"Stopped processing for target {self._target_id}")
self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": False})
@@ -228,6 +249,33 @@ class WledTargetProcessor(TargetProcessor):
self._css_stream = new_stream
logger.info(f"Hot-swapped CSS for {self._target_id}: {old_css_id} -> {new_css_id}")
def update_brightness_value_source(self, vs_id: str) -> None:
"""Hot-swap the brightness value source for a running target."""
old_vs_id = self._brightness_vs_id
self._brightness_vs_id = vs_id
vs_mgr = self._ctx.value_stream_manager
if not self._is_running or vs_mgr is None:
return
# Release old stream
if self._value_stream is not None and old_vs_id:
try:
vs_mgr.release(old_vs_id, self._target_id)
except Exception as e:
logger.warning(f"Error releasing old value stream {old_vs_id}: {e}")
self._value_stream = None
# Acquire new stream
if vs_id:
try:
self._value_stream = vs_mgr.acquire(vs_id, self._target_id)
except Exception as e:
logger.warning(f"Failed to acquire value stream {vs_id}: {e}")
self._value_stream = None
logger.info(f"Hot-swapped brightness VS for {self._target_id}: {old_vs_id} -> {vs_id}")
def get_display_index(self) -> Optional[int]:
"""Display index being captured, from the active stream."""
if self._resolved_display_index is not None:
@@ -261,6 +309,7 @@ class WledTargetProcessor(TargetProcessor):
"target_id": self._target_id,
"device_id": self._device_id,
"color_strip_source_id": self._css_id,
"brightness_value_source_id": self._brightness_vs_id,
"processing": self._is_running,
"fps_actual": metrics.fps_actual if self._is_running else None,
"fps_potential": metrics.fps_potential if self._is_running else None,
@@ -392,9 +441,9 @@ class WledTargetProcessor(TargetProcessor):
_bright_out: Optional[np.ndarray] = None
_bright_n = 0
def _cached_brightness(colors_in, dev_info):
def _cached_brightness(colors_in, brightness: int):
nonlocal _bright_n, _bright_u16, _bright_out
if not dev_info or dev_info.software_brightness >= 255:
if brightness >= 255:
return colors_in
_dn = len(colors_in)
if _dn != _bright_n:
@@ -402,11 +451,20 @@ class WledTargetProcessor(TargetProcessor):
_bright_u16 = np.empty((_dn, 3), dtype=np.uint16)
_bright_out = np.empty((_dn, 3), dtype=np.uint8)
np.copyto(_bright_u16, colors_in, casting='unsafe')
_bright_u16 *= dev_info.software_brightness
_bright_u16 *= brightness
_bright_u16 >>= 8
np.copyto(_bright_out, _bright_u16, casting='unsafe')
return _bright_out
def _effective_brightness(dev_info):
"""Compute effective brightness = software_brightness * value_stream."""
base = dev_info.software_brightness if dev_info else 255
vs = self._value_stream
if vs is not None:
vs_val = vs.get_value()
return max(0, min(255, int(base * vs_val)))
return base
SKIP_REPOLL = 0.005 # 5 ms
# --- Timing diagnostics ---
@@ -471,7 +529,8 @@ class WledTargetProcessor(TargetProcessor):
if not self._is_running or self._led_client is None:
break
send_colors = _cached_brightness(
self._fit_to_device(prev_frame_ref, _total_leds), device_info
self._fit_to_device(prev_frame_ref, _total_leds),
_effective_brightness(device_info),
)
if self._led_client.supports_fast_send:
self._led_client.send_pixels_fast(send_colors)
@@ -495,7 +554,7 @@ class WledTargetProcessor(TargetProcessor):
# Fit to device LED count and apply brightness
device_colors = self._fit_to_device(frame, _total_leds)
send_colors = _cached_brightness(device_colors, device_info)
send_colors = _cached_brightness(device_colors, _effective_brightness(device_info))
# Send to LED device
if not self._is_running or self._led_client is None: