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:
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
389
server/src/wled_controller/core/processing/value_stream.py
Normal file
389
server/src/wled_controller/core/processing/value_stream.py
Normal file
@@ -0,0 +1,389 @@
|
||||
"""Value stream — runtime scalar signal generators.
|
||||
|
||||
A ValueStream wraps a ValueSource config and computes a float (0.0–1.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.0–1.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)
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user