diff --git a/server/src/wled_controller/api/__init__.py b/server/src/wled_controller/api/__init__.py index 168725c..25c81e5 100644 --- a/server/src/wled_controller/api/__init__.py +++ b/server/src/wled_controller/api/__init__.py @@ -12,6 +12,7 @@ from .routes.picture_targets import router as picture_targets_router from .routes.color_strip_sources import router as color_strip_sources_router from .routes.audio import router as audio_router from .routes.audio_sources import router as audio_sources_router +from .routes.value_sources import router as value_sources_router from .routes.profiles import router as profiles_router router = APIRouter() @@ -24,6 +25,7 @@ router.include_router(picture_sources_router) router.include_router(color_strip_sources_router) router.include_router(audio_router) router.include_router(audio_sources_router) +router.include_router(value_sources_router) router.include_router(picture_targets_router) router.include_router(profiles_router) diff --git a/server/src/wled_controller/api/dependencies.py b/server/src/wled_controller/api/dependencies.py index 60c97e8..76916c4 100644 --- a/server/src/wled_controller/api/dependencies.py +++ b/server/src/wled_controller/api/dependencies.py @@ -9,6 +9,7 @@ from wled_controller.storage.picture_source_store import PictureSourceStore from wled_controller.storage.picture_target_store import PictureTargetStore from wled_controller.storage.color_strip_store import ColorStripStore from wled_controller.storage.audio_source_store import AudioSourceStore +from wled_controller.storage.value_source_store import ValueSourceStore from wled_controller.storage.profile_store import ProfileStore from wled_controller.core.profiles.profile_engine import ProfileEngine @@ -21,6 +22,7 @@ _picture_source_store: PictureSourceStore | None = None _picture_target_store: PictureTargetStore | None = None _color_strip_store: ColorStripStore | None = None _audio_source_store: AudioSourceStore | None = None +_value_source_store: ValueSourceStore | None = None _processor_manager: ProcessorManager | None = None _profile_store: ProfileStore | None = None _profile_engine: ProfileEngine | None = None @@ -82,6 +84,13 @@ def get_audio_source_store() -> AudioSourceStore: return _audio_source_store +def get_value_source_store() -> ValueSourceStore: + """Get value source store dependency.""" + if _value_source_store is None: + raise RuntimeError("Value source store not initialized") + return _value_source_store + + def get_processor_manager() -> ProcessorManager: """Get processor manager dependency.""" if _processor_manager is None: @@ -113,13 +122,14 @@ def init_dependencies( picture_target_store: PictureTargetStore | None = None, color_strip_store: ColorStripStore | None = None, audio_source_store: AudioSourceStore | None = None, + value_source_store: ValueSourceStore | None = None, profile_store: ProfileStore | None = None, profile_engine: ProfileEngine | None = None, ): """Initialize global dependencies.""" global _device_store, _template_store, _processor_manager global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_store - global _color_strip_store, _audio_source_store, _profile_store, _profile_engine + global _color_strip_store, _audio_source_store, _value_source_store, _profile_store, _profile_engine _device_store = device_store _template_store = template_store _processor_manager = processor_manager @@ -129,5 +139,6 @@ def init_dependencies( _picture_target_store = picture_target_store _color_strip_store = color_strip_store _audio_source_store = audio_source_store + _value_source_store = value_source_store _profile_store = profile_store _profile_engine = profile_engine diff --git a/server/src/wled_controller/api/routes/picture_targets.py b/server/src/wled_controller/api/routes/picture_targets.py index c0a81c1..dcfb3f6 100644 --- a/server/src/wled_controller/api/routes/picture_targets.py +++ b/server/src/wled_controller/api/routes/picture_targets.py @@ -94,6 +94,7 @@ def _target_to_response(target) -> PictureTargetResponse: target_type=target.target_type, device_id=target.device_id, color_strip_source_id=target.color_strip_source_id, + brightness_value_source_id=target.brightness_value_source_id, fps=target.fps, keepalive_interval=target.keepalive_interval, state_check_interval=target.state_check_interval, @@ -149,6 +150,7 @@ async def create_target( target_type=data.target_type, device_id=data.device_id, color_strip_source_id=data.color_strip_source_id, + brightness_value_source_id=data.brightness_value_source_id, fps=data.fps, keepalive_interval=data.keepalive_interval, state_check_interval=data.state_check_interval, @@ -263,6 +265,7 @@ async def update_target( name=data.name, device_id=data.device_id, color_strip_source_id=data.color_strip_source_id, + brightness_value_source_id=data.brightness_value_source_id, fps=data.fps, keepalive_interval=data.keepalive_interval, state_check_interval=data.state_check_interval, @@ -280,6 +283,7 @@ async def update_target( data.key_colors_settings is not None), css_changed=data.color_strip_source_id is not None, device_changed=data.device_id is not None, + brightness_vs_changed=data.brightness_value_source_id is not None, ) except ValueError: pass diff --git a/server/src/wled_controller/api/routes/value_sources.py b/server/src/wled_controller/api/routes/value_sources.py new file mode 100644 index 0000000..4799086 --- /dev/null +++ b/server/src/wled_controller/api/routes/value_sources.py @@ -0,0 +1,161 @@ +"""Value source routes: CRUD for value sources.""" + +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query + +from wled_controller.api.auth import AuthRequired +from wled_controller.api.dependencies import ( + get_picture_target_store, + get_processor_manager, + get_value_source_store, +) +from wled_controller.api.schemas.value_sources import ( + ValueSourceCreate, + ValueSourceListResponse, + ValueSourceResponse, + ValueSourceUpdate, +) +from wled_controller.storage.value_source import ValueSource +from wled_controller.storage.value_source_store import ValueSourceStore +from wled_controller.storage.picture_target_store import PictureTargetStore +from wled_controller.core.processing.processor_manager import ProcessorManager +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + +router = APIRouter() + + +def _to_response(source: ValueSource) -> ValueSourceResponse: + """Convert a ValueSource to a ValueSourceResponse.""" + d = source.to_dict() + return ValueSourceResponse( + id=d["id"], + name=d["name"], + source_type=d["source_type"], + value=d.get("value"), + waveform=d.get("waveform"), + speed=d.get("speed"), + min_value=d.get("min_value"), + max_value=d.get("max_value"), + audio_source_id=d.get("audio_source_id"), + mode=d.get("mode"), + sensitivity=d.get("sensitivity"), + smoothing=d.get("smoothing"), + description=d.get("description"), + created_at=source.created_at, + updated_at=source.updated_at, + ) + + +@router.get("/api/v1/value-sources", response_model=ValueSourceListResponse, tags=["Value Sources"]) +async def list_value_sources( + _auth: AuthRequired, + source_type: Optional[str] = Query(None, description="Filter by source_type: static, animated, or audio"), + store: ValueSourceStore = Depends(get_value_source_store), +): + """List all value sources, optionally filtered by type.""" + sources = store.get_all_sources() + if source_type: + sources = [s for s in sources if s.source_type == source_type] + return ValueSourceListResponse( + sources=[_to_response(s) for s in sources], + count=len(sources), + ) + + +@router.post("/api/v1/value-sources", response_model=ValueSourceResponse, status_code=201, tags=["Value Sources"]) +async def create_value_source( + data: ValueSourceCreate, + _auth: AuthRequired, + store: ValueSourceStore = Depends(get_value_source_store), +): + """Create a new value source.""" + try: + source = store.create_source( + name=data.name, + source_type=data.source_type, + value=data.value, + waveform=data.waveform, + speed=data.speed, + min_value=data.min_value, + max_value=data.max_value, + audio_source_id=data.audio_source_id, + mode=data.mode, + sensitivity=data.sensitivity, + smoothing=data.smoothing, + description=data.description, + ) + return _to_response(source) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/api/v1/value-sources/{source_id}", response_model=ValueSourceResponse, tags=["Value Sources"]) +async def get_value_source( + source_id: str, + _auth: AuthRequired, + store: ValueSourceStore = Depends(get_value_source_store), +): + """Get a value source by ID.""" + try: + source = store.get_source(source_id) + return _to_response(source) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + +@router.put("/api/v1/value-sources/{source_id}", response_model=ValueSourceResponse, tags=["Value Sources"]) +async def update_value_source( + source_id: str, + data: ValueSourceUpdate, + _auth: AuthRequired, + store: ValueSourceStore = Depends(get_value_source_store), + pm: ProcessorManager = Depends(get_processor_manager), +): + """Update an existing value source.""" + try: + source = store.update_source( + source_id=source_id, + name=data.name, + value=data.value, + waveform=data.waveform, + speed=data.speed, + min_value=data.min_value, + max_value=data.max_value, + audio_source_id=data.audio_source_id, + mode=data.mode, + sensitivity=data.sensitivity, + smoothing=data.smoothing, + description=data.description, + ) + # Hot-reload running value streams + pm.update_value_source(source_id) + return _to_response(source) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.delete("/api/v1/value-sources/{source_id}", tags=["Value Sources"]) +async def delete_value_source( + source_id: str, + _auth: AuthRequired, + store: ValueSourceStore = Depends(get_value_source_store), + target_store: PictureTargetStore = Depends(get_picture_target_store), +): + """Delete a value source.""" + try: + # Check if any targets reference this value source + from wled_controller.storage.wled_picture_target import WledPictureTarget + for target in target_store.get_all_targets(): + if isinstance(target, WledPictureTarget): + if getattr(target, "brightness_value_source_id", "") == source_id: + raise ValueError( + f"Cannot delete: referenced by target '{target.name}'" + ) + + store.delete_source(source_id) + return {"status": "deleted", "id": source_id} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) diff --git a/server/src/wled_controller/api/schemas/picture_targets.py b/server/src/wled_controller/api/schemas/picture_targets.py index bd08d3a..09dab7c 100644 --- a/server/src/wled_controller/api/schemas/picture_targets.py +++ b/server/src/wled_controller/api/schemas/picture_targets.py @@ -53,6 +53,7 @@ class PictureTargetCreate(BaseModel): # LED target fields device_id: str = Field(default="", description="LED device ID") color_strip_source_id: str = Field(default="", description="Color strip source ID") + brightness_value_source_id: str = Field(default="", description="Brightness value source ID") fps: int = Field(default=30, ge=1, le=90, description="Target send FPS (1-90)") keepalive_interval: float = Field(default=1.0, description="Keepalive send interval when screen is static (0.5-5.0s)", ge=0.5, le=5.0) state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Device health check interval (5-600s)", ge=5, le=600) @@ -69,6 +70,7 @@ class PictureTargetUpdate(BaseModel): # LED target fields device_id: Optional[str] = Field(None, description="LED device ID") color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID") + brightness_value_source_id: Optional[str] = Field(None, description="Brightness value source ID") fps: Optional[int] = Field(None, ge=1, le=90, description="Target send FPS (1-90)") keepalive_interval: Optional[float] = Field(None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0) state_check_interval: Optional[int] = Field(None, description="Health check interval (5-600s)", ge=5, le=600) @@ -87,6 +89,7 @@ class PictureTargetResponse(BaseModel): # LED target fields device_id: str = Field(default="", description="LED device ID") color_strip_source_id: str = Field(default="", description="Color strip source ID") + brightness_value_source_id: str = Field(default="", description="Brightness value source ID") fps: Optional[int] = Field(None, description="Target send FPS") keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)") state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)") diff --git a/server/src/wled_controller/api/schemas/value_sources.py b/server/src/wled_controller/api/schemas/value_sources.py new file mode 100644 index 0000000..62b6654 --- /dev/null +++ b/server/src/wled_controller/api/schemas/value_sources.py @@ -0,0 +1,72 @@ +"""Value source schemas (CRUD).""" + +from datetime import datetime +from typing import List, Literal, Optional + +from pydantic import BaseModel, Field + + +class ValueSourceCreate(BaseModel): + """Request to create a value source.""" + + name: str = Field(description="Source name", min_length=1, max_length=100) + source_type: Literal["static", "animated", "audio"] = Field(description="Source type") + # static fields + value: Optional[float] = Field(None, description="Constant value (0.0-1.0)", ge=0.0, le=1.0) + # animated fields + waveform: Optional[str] = Field(None, description="Waveform: sine|triangle|square|sawtooth") + speed: Optional[float] = Field(None, description="Cycles per minute (1.0-120.0)", ge=1.0, le=120.0) + min_value: Optional[float] = Field(None, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0) + max_value: Optional[float] = Field(None, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0) + # audio fields + audio_source_id: Optional[str] = Field(None, description="Mono audio source ID") + mode: Optional[str] = Field(None, description="Audio mode: rms|peak|beat") + sensitivity: Optional[float] = Field(None, description="Gain multiplier (0.1-5.0)", ge=0.1, le=5.0) + smoothing: Optional[float] = Field(None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0) + description: Optional[str] = Field(None, description="Optional description", max_length=500) + + +class ValueSourceUpdate(BaseModel): + """Request to update a value source.""" + + name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100) + # static fields + value: Optional[float] = Field(None, description="Constant value (0.0-1.0)", ge=0.0, le=1.0) + # animated fields + waveform: Optional[str] = Field(None, description="Waveform: sine|triangle|square|sawtooth") + speed: Optional[float] = Field(None, description="Cycles per minute (1.0-120.0)", ge=1.0, le=120.0) + min_value: Optional[float] = Field(None, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0) + max_value: Optional[float] = Field(None, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0) + # audio fields + audio_source_id: Optional[str] = Field(None, description="Mono audio source ID") + mode: Optional[str] = Field(None, description="Audio mode: rms|peak|beat") + sensitivity: Optional[float] = Field(None, description="Gain multiplier (0.1-5.0)", ge=0.1, le=5.0) + smoothing: Optional[float] = Field(None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0) + description: Optional[str] = Field(None, description="Optional description", max_length=500) + + +class ValueSourceResponse(BaseModel): + """Value source response.""" + + id: str = Field(description="Source ID") + name: str = Field(description="Source name") + source_type: str = Field(description="Source type: static, animated, or audio") + value: Optional[float] = Field(None, description="Static value") + waveform: Optional[str] = Field(None, description="Waveform type") + speed: Optional[float] = Field(None, description="Cycles per minute") + min_value: Optional[float] = Field(None, description="Minimum output") + max_value: Optional[float] = Field(None, description="Maximum output") + audio_source_id: Optional[str] = Field(None, description="Mono audio source ID") + mode: Optional[str] = Field(None, description="Audio mode") + sensitivity: Optional[float] = Field(None, description="Gain multiplier") + smoothing: Optional[float] = Field(None, description="Temporal smoothing") + description: Optional[str] = Field(None, description="Description") + created_at: datetime = Field(description="Creation timestamp") + updated_at: datetime = Field(description="Last update timestamp") + + +class ValueSourceListResponse(BaseModel): + """List of value sources.""" + + sources: List[ValueSourceResponse] = Field(description="List of value sources") + count: int = Field(description="Number of sources") diff --git a/server/src/wled_controller/config.py b/server/src/wled_controller/config.py index dbea9d3..95595c4 100644 --- a/server/src/wled_controller/config.py +++ b/server/src/wled_controller/config.py @@ -35,6 +35,7 @@ class StorageConfig(BaseSettings): pattern_templates_file: str = "data/pattern_templates.json" color_strip_sources_file: str = "data/color_strip_sources.json" audio_sources_file: str = "data/audio_sources.json" + value_sources_file: str = "data/value_sources.json" profiles_file: str = "data/profiles.json" diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index 4888f4b..1384060 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -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() diff --git a/server/src/wled_controller/core/processing/target_processor.py b/server/src/wled_controller/core/processing/target_processor.py index 5f31ae1..dc74550 100644 --- a/server/src/wled_controller/core/processing/target_processor.py +++ b/server/src/wled_controller/core/processing/target_processor.py @@ -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 diff --git a/server/src/wled_controller/core/processing/value_stream.py b/server/src/wled_controller/core/processing/value_stream.py new file mode 100644 index 0000000..f00f330 --- /dev/null +++ b/server/src/wled_controller/core/processing/value_stream.py @@ -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) diff --git a/server/src/wled_controller/core/processing/wled_target_processor.py b/server/src/wled_controller/core/processing/wled_target_processor.py index 7895972..91a29ae 100644 --- a/server/src/wled_controller/core/processing/wled_target_processor.py +++ b/server/src/wled_controller/core/processing/wled_target_processor.py @@ -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: diff --git a/server/src/wled_controller/main.py b/server/src/wled_controller/main.py index 448bfa7..db5082f 100644 --- a/server/src/wled_controller/main.py +++ b/server/src/wled_controller/main.py @@ -24,6 +24,7 @@ from wled_controller.storage.picture_source_store import PictureSourceStore from wled_controller.storage.picture_target_store import PictureTargetStore from wled_controller.storage.color_strip_store import ColorStripStore from wled_controller.storage.audio_source_store import AudioSourceStore +from wled_controller.storage.value_source_store import ValueSourceStore from wled_controller.storage.profile_store import ProfileStore from wled_controller.core.profiles.profile_engine import ProfileEngine from wled_controller.utils import setup_logging, get_logger @@ -44,6 +45,7 @@ picture_target_store = PictureTargetStore(config.storage.picture_targets_file) pattern_template_store = PatternTemplateStore(config.storage.pattern_templates_file) color_strip_store = ColorStripStore(config.storage.color_strip_sources_file) audio_source_store = AudioSourceStore(config.storage.audio_sources_file) +value_source_store = ValueSourceStore(config.storage.value_sources_file) profile_store = ProfileStore(config.storage.profiles_file) # Migrate embedded audio config from CSS entities to audio sources @@ -57,6 +59,7 @@ processor_manager = ProcessorManager( device_store=device_store, color_strip_store=color_strip_store, audio_source_store=audio_source_store, + value_source_store=value_source_store, ) @@ -101,6 +104,7 @@ async def lifespan(app: FastAPI): picture_target_store=picture_target_store, color_strip_store=color_strip_store, audio_source_store=audio_source_store, + value_source_store=value_source_store, profile_store=profile_store, profile_engine=profile_engine, ) diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index 22fe7ad..e3b8254 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -108,6 +108,12 @@ import { editAudioSource, deleteAudioSource, onAudioSourceTypeChange, } from './features/audio-sources.js'; +// Layer 5: value sources +import { + showValueSourceModal, closeValueSourceModal, saveValueSource, + editValueSource, deleteValueSource, onValueSourceTypeChange, +} from './features/value-sources.js'; + // Layer 5: calibration import { showCalibration, closeCalibrationModal, forceCloseCalibrationModal, saveCalibration, @@ -317,6 +323,14 @@ Object.assign(window, { deleteAudioSource, onAudioSourceTypeChange, + // value sources + showValueSourceModal, + closeValueSourceModal, + saveValueSource, + editValueSource, + deleteValueSource, + onValueSourceTypeChange, + // calibration showCalibration, closeCalibrationModal, diff --git a/server/src/wled_controller/static/js/core/state.js b/server/src/wled_controller/static/js/core/state.js index f9e0b60..fbdfbfa 100644 --- a/server/src/wled_controller/static/js/core/state.js +++ b/server/src/wled_controller/static/js/core/state.js @@ -169,6 +169,10 @@ export const PATTERN_RECT_BORDERS = [ export let _cachedAudioSources = []; export function set_cachedAudioSources(v) { _cachedAudioSources = v; } +// Value sources +export let _cachedValueSources = []; +export function set_cachedValueSources(v) { _cachedValueSources = v; } + // Profiles export let _profilesCache = null; export function set_profilesCache(v) { _profilesCache = v; } diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js index c4dedcf..3a0db87 100644 --- a/server/src/wled_controller/static/js/features/streams.js +++ b/server/src/wled_controller/static/js/features/streams.js @@ -19,6 +19,7 @@ import { _currentTestPPTemplateId, set_currentTestPPTemplateId, _lastValidatedImageSource, set_lastValidatedImageSource, _cachedAudioSources, set_cachedAudioSources, + _cachedValueSources, set_cachedValueSources, apiKey, } from '../core/state.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js'; @@ -27,6 +28,7 @@ import { Modal } from '../core/modal.js'; import { showToast, showConfirm, openLightbox, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner } from '../core/ui.js'; import { openDisplayPicker, formatDisplayLabel } from './displays.js'; import { CardSection } from '../core/card-sections.js'; +import { createValueSourceCard } from './value-sources.js'; // ── Card section instances ── const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')" }); @@ -36,6 +38,7 @@ const csProcTemplates = new CardSection('proc-templates', { titleKey: 'postproce const csAudioMulti = new CardSection('audio-multi', { titleKey: 'audio_source.group.multichannel', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('multichannel')" }); const csAudioMono = new CardSection('audio-mono', { titleKey: 'audio_source.group.mono', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('mono')" }); const csStaticStreams = new CardSection('static-streams', { titleKey: 'streams.group.static_image', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('static_image')" }); +const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()" }); // Re-render picture sources when language changes document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); }); @@ -447,12 +450,13 @@ export async function deleteTemplate(templateId) { export async function loadPictureSources() { try { - const [filtersResp, ppResp, captResp, streamsResp, audioResp] = await Promise.all([ + const [filtersResp, ppResp, captResp, streamsResp, audioResp, valueResp] = await Promise.all([ _availableFilters.length === 0 ? fetchWithAuth('/filters') : Promise.resolve(null), fetchWithAuth('/postprocessing-templates'), fetchWithAuth('/capture-templates'), fetchWithAuth('/picture-sources'), fetchWithAuth('/audio-sources'), + fetchWithAuth('/value-sources'), ]); if (filtersResp && filtersResp.ok) { @@ -471,6 +475,10 @@ export async function loadPictureSources() { const ad = await audioResp.json(); set_cachedAudioSources(ad.sources || []); } + if (valueResp && valueResp.ok) { + const vd = await valueResp.json(); + set_cachedValueSources(vd.sources || []); + } if (!streamsResp.ok) throw new Error(`Failed to load streams: ${streamsResp.status}`); const data = await streamsResp.json(); set_cachedStreams(data.streams || []); @@ -621,6 +629,7 @@ function renderPictureSourcesList(streams) { { key: 'static_image', icon: '🖼️', titleKey: 'streams.group.static_image', count: staticImageStreams.length }, { key: 'processed', icon: '🎨', titleKey: 'streams.group.processed', count: processedStreams.length }, { key: 'audio', icon: '🔊', titleKey: 'streams.group.audio', count: _cachedAudioSources.length }, + { key: 'value', icon: '🎚️', titleKey: 'streams.group.value', count: _cachedValueSources.length }, ]; const tabBar = `
+