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

@@ -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.color_strip_sources import router as color_strip_sources_router
from .routes.audio import router as audio_router from .routes.audio import router as audio_router
from .routes.audio_sources import router as audio_sources_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 from .routes.profiles import router as profiles_router
router = APIRouter() router = APIRouter()
@@ -24,6 +25,7 @@ router.include_router(picture_sources_router)
router.include_router(color_strip_sources_router) router.include_router(color_strip_sources_router)
router.include_router(audio_router) router.include_router(audio_router)
router.include_router(audio_sources_router) router.include_router(audio_sources_router)
router.include_router(value_sources_router)
router.include_router(picture_targets_router) router.include_router(picture_targets_router)
router.include_router(profiles_router) router.include_router(profiles_router)

View File

@@ -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.picture_target_store import PictureTargetStore
from wled_controller.storage.color_strip_store import ColorStripStore from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.audio_source_store import AudioSourceStore 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.storage.profile_store import ProfileStore
from wled_controller.core.profiles.profile_engine import ProfileEngine 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 _picture_target_store: PictureTargetStore | None = None
_color_strip_store: ColorStripStore | None = None _color_strip_store: ColorStripStore | None = None
_audio_source_store: AudioSourceStore | None = None _audio_source_store: AudioSourceStore | None = None
_value_source_store: ValueSourceStore | None = None
_processor_manager: ProcessorManager | None = None _processor_manager: ProcessorManager | None = None
_profile_store: ProfileStore | None = None _profile_store: ProfileStore | None = None
_profile_engine: ProfileEngine | None = None _profile_engine: ProfileEngine | None = None
@@ -82,6 +84,13 @@ def get_audio_source_store() -> AudioSourceStore:
return _audio_source_store 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: def get_processor_manager() -> ProcessorManager:
"""Get processor manager dependency.""" """Get processor manager dependency."""
if _processor_manager is None: if _processor_manager is None:
@@ -113,13 +122,14 @@ def init_dependencies(
picture_target_store: PictureTargetStore | None = None, picture_target_store: PictureTargetStore | None = None,
color_strip_store: ColorStripStore | None = None, color_strip_store: ColorStripStore | None = None,
audio_source_store: AudioSourceStore | None = None, audio_source_store: AudioSourceStore | None = None,
value_source_store: ValueSourceStore | None = None,
profile_store: ProfileStore | None = None, profile_store: ProfileStore | None = None,
profile_engine: ProfileEngine | None = None, profile_engine: ProfileEngine | None = None,
): ):
"""Initialize global dependencies.""" """Initialize global dependencies."""
global _device_store, _template_store, _processor_manager global _device_store, _template_store, _processor_manager
global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_store 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 _device_store = device_store
_template_store = template_store _template_store = template_store
_processor_manager = processor_manager _processor_manager = processor_manager
@@ -129,5 +139,6 @@ def init_dependencies(
_picture_target_store = picture_target_store _picture_target_store = picture_target_store
_color_strip_store = color_strip_store _color_strip_store = color_strip_store
_audio_source_store = audio_source_store _audio_source_store = audio_source_store
_value_source_store = value_source_store
_profile_store = profile_store _profile_store = profile_store
_profile_engine = profile_engine _profile_engine = profile_engine

View File

@@ -94,6 +94,7 @@ def _target_to_response(target) -> PictureTargetResponse:
target_type=target.target_type, target_type=target.target_type,
device_id=target.device_id, device_id=target.device_id,
color_strip_source_id=target.color_strip_source_id, color_strip_source_id=target.color_strip_source_id,
brightness_value_source_id=target.brightness_value_source_id,
fps=target.fps, fps=target.fps,
keepalive_interval=target.keepalive_interval, keepalive_interval=target.keepalive_interval,
state_check_interval=target.state_check_interval, state_check_interval=target.state_check_interval,
@@ -149,6 +150,7 @@ async def create_target(
target_type=data.target_type, target_type=data.target_type,
device_id=data.device_id, device_id=data.device_id,
color_strip_source_id=data.color_strip_source_id, color_strip_source_id=data.color_strip_source_id,
brightness_value_source_id=data.brightness_value_source_id,
fps=data.fps, fps=data.fps,
keepalive_interval=data.keepalive_interval, keepalive_interval=data.keepalive_interval,
state_check_interval=data.state_check_interval, state_check_interval=data.state_check_interval,
@@ -263,6 +265,7 @@ async def update_target(
name=data.name, name=data.name,
device_id=data.device_id, device_id=data.device_id,
color_strip_source_id=data.color_strip_source_id, color_strip_source_id=data.color_strip_source_id,
brightness_value_source_id=data.brightness_value_source_id,
fps=data.fps, fps=data.fps,
keepalive_interval=data.keepalive_interval, keepalive_interval=data.keepalive_interval,
state_check_interval=data.state_check_interval, state_check_interval=data.state_check_interval,
@@ -280,6 +283,7 @@ async def update_target(
data.key_colors_settings is not None), data.key_colors_settings is not None),
css_changed=data.color_strip_source_id is not None, css_changed=data.color_strip_source_id is not None,
device_changed=data.device_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: except ValueError:
pass pass

View File

@@ -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))

View File

@@ -53,6 +53,7 @@ class PictureTargetCreate(BaseModel):
# LED target fields # LED target fields
device_id: str = Field(default="", description="LED device ID") device_id: str = Field(default="", description="LED device ID")
color_strip_source_id: str = Field(default="", description="Color strip source 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)") 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) 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) 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 # LED target fields
device_id: Optional[str] = Field(None, description="LED device ID") device_id: Optional[str] = Field(None, description="LED device ID")
color_strip_source_id: Optional[str] = Field(None, description="Color strip source 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)") 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) 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) 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 # LED target fields
device_id: str = Field(default="", description="LED device ID") device_id: str = Field(default="", description="LED device ID")
color_strip_source_id: str = Field(default="", description="Color strip source 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") fps: Optional[int] = Field(None, description="Target send FPS")
keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)") 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)") state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)")

View File

@@ -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")

View File

@@ -35,6 +35,7 @@ class StorageConfig(BaseSettings):
pattern_templates_file: str = "data/pattern_templates.json" pattern_templates_file: str = "data/pattern_templates.json"
color_strip_sources_file: str = "data/color_strip_sources.json" color_strip_sources_file: str = "data/color_strip_sources.json"
audio_sources_file: str = "data/audio_sources.json" audio_sources_file: str = "data/audio_sources.json"
value_sources_file: str = "data/value_sources.json"
profiles_file: str = "data/profiles.json" profiles_file: str = "data/profiles.json"

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.audio.audio_capture import AudioCaptureManager
from wled_controller.core.processing.live_stream_manager import LiveStreamManager 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.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.capture.screen_overlay import OverlayManager
from wled_controller.core.processing.target_processor import ( from wled_controller.core.processing.target_processor import (
DeviceInfo, DeviceInfo,
@@ -64,7 +65,7 @@ class ProcessorManager:
Targets are registered for processing via polymorphic TargetProcessor subclasses. 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.""" """Initialize processor manager."""
self._devices: Dict[str, DeviceState] = {} self._devices: Dict[str, DeviceState] = {}
self._processors: Dict[str, TargetProcessor] = {} self._processors: Dict[str, TargetProcessor] = {}
@@ -78,6 +79,7 @@ class ProcessorManager:
self._device_store = device_store self._device_store = device_store
self._color_strip_store = color_strip_store self._color_strip_store = color_strip_store
self._audio_source_store = audio_source_store self._audio_source_store = audio_source_store
self._value_source_store = value_source_store
self._live_stream_manager = LiveStreamManager( self._live_stream_manager = LiveStreamManager(
picture_source_store, capture_template_store, pp_template_store picture_source_store, capture_template_store, pp_template_store
) )
@@ -88,6 +90,11 @@ class ProcessorManager:
audio_capture_manager=self._audio_capture_manager, audio_capture_manager=self._audio_capture_manager,
audio_source_store=audio_source_store, 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._overlay_manager = OverlayManager()
self._event_queues: List[asyncio.Queue] = [] self._event_queues: List[asyncio.Queue] = []
logger.info("Processor manager initialized") logger.info("Processor manager initialized")
@@ -105,6 +112,7 @@ class ProcessorManager:
pattern_template_store=self._pattern_template_store, pattern_template_store=self._pattern_template_store,
device_store=self._device_store, device_store=self._device_store,
color_strip_stream_manager=self._color_strip_stream_manager, color_strip_stream_manager=self._color_strip_stream_manager,
value_stream_manager=self._value_stream_manager,
fire_event=self._fire_event, fire_event=self._fire_event,
get_device_info=self._get_device_info, get_device_info=self._get_device_info,
) )
@@ -276,6 +284,7 @@ class ProcessorManager:
fps: int = 30, fps: int = 30,
keepalive_interval: float = 1.0, keepalive_interval: float = 1.0,
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL, state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
brightness_value_source_id: str = "",
): ):
"""Register a WLED target processor.""" """Register a WLED target processor."""
if target_id in self._processors: if target_id in self._processors:
@@ -290,6 +299,7 @@ class ProcessorManager:
fps=fps, fps=fps,
keepalive_interval=keepalive_interval, keepalive_interval=keepalive_interval,
state_check_interval=state_check_interval, state_check_interval=state_check_interval,
brightness_value_source_id=brightness_value_source_id,
ctx=self._build_context(), ctx=self._build_context(),
) )
self._processors[target_id] = proc self._processors[target_id] = proc
@@ -347,6 +357,17 @@ class ProcessorManager:
raise ValueError(f"Device {device_id} not registered") raise ValueError(f"Device {device_id} not registered")
proc.update_device(device_id) 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): async def start_processing(self, target_id: str):
"""Start processing for a target (any type).""" """Start processing for a target (any type)."""
proc = self._get_processor(target_id) proc = self._get_processor(target_id)
@@ -719,6 +740,10 @@ class ProcessorManager:
# Safety net: release all color strip streams # Safety net: release all color strip streams
self._color_strip_stream_manager.release_all() 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 # Safety net: release any remaining managed live streams
self._live_stream_manager.release_all() 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: if TYPE_CHECKING:
from wled_controller.core.processing.color_strip_stream_manager import ColorStripStreamManager 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.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.core.capture.screen_overlay import OverlayManager
from wled_controller.storage import DeviceStore from wled_controller.storage import DeviceStore
from wled_controller.storage.picture_source_store import PictureSourceStore from wled_controller.storage.picture_source_store import PictureSourceStore
@@ -85,6 +86,7 @@ class TargetContext:
pattern_template_store: Optional["PatternTemplateStore"] = None pattern_template_store: Optional["PatternTemplateStore"] = None
device_store: Optional["DeviceStore"] = None device_store: Optional["DeviceStore"] = None
color_strip_stream_manager: Optional["ColorStripStreamManager"] = None color_strip_stream_manager: Optional["ColorStripStreamManager"] = None
value_stream_manager: Optional["ValueStreamManager"] = None
fire_event: Callable[[dict], None] = lambda e: None fire_event: Callable[[dict], None] = lambda e: None
get_device_info: Callable[[str], Optional[DeviceInfo]] = lambda _: 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, fps: int = 30,
keepalive_interval: float = 1.0, keepalive_interval: float = 1.0,
state_check_interval: int = 30, state_check_interval: int = 30,
brightness_value_source_id: str = "",
ctx: TargetContext = None, ctx: TargetContext = None,
): ):
super().__init__(target_id, ctx) super().__init__(target_id, ctx)
@@ -43,10 +44,12 @@ class WledTargetProcessor(TargetProcessor):
self._keepalive_interval = keepalive_interval self._keepalive_interval = keepalive_interval
self._state_check_interval = state_check_interval self._state_check_interval = state_check_interval
self._css_id = color_strip_source_id self._css_id = color_strip_source_id
self._brightness_vs_id = brightness_value_source_id
# Runtime state (populated on start) # Runtime state (populated on start)
self._led_client: Optional[LEDClient] = None self._led_client: Optional[LEDClient] = None
self._css_stream: Optional[object] = None # active stream reference 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._device_state_before: Optional[dict] = None
self._overlay_active = False self._overlay_active = False
self._needs_keepalive = True self._needs_keepalive = True
@@ -122,6 +125,16 @@ class WledTargetProcessor(TargetProcessor):
self._led_client = None self._led_client = None
raise RuntimeError(f"Failed to acquire CSS stream: {e}") 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 # Reset metrics and start loop
self._metrics = ProcessingMetrics(start_time=datetime.utcnow()) self._metrics = ProcessingMetrics(start_time=datetime.utcnow())
self._task = asyncio.create_task(self._processing_loop()) 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}") logger.warning(f"Error releasing CSS stream {self._css_id} for {self._target_id}: {e}")
self._css_stream = None 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}") logger.info(f"Stopped processing for target {self._target_id}")
self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": False}) 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 self._css_stream = new_stream
logger.info(f"Hot-swapped CSS for {self._target_id}: {old_css_id} -> {new_css_id}") 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]: def get_display_index(self) -> Optional[int]:
"""Display index being captured, from the active stream.""" """Display index being captured, from the active stream."""
if self._resolved_display_index is not None: if self._resolved_display_index is not None:
@@ -261,6 +309,7 @@ class WledTargetProcessor(TargetProcessor):
"target_id": self._target_id, "target_id": self._target_id,
"device_id": self._device_id, "device_id": self._device_id,
"color_strip_source_id": self._css_id, "color_strip_source_id": self._css_id,
"brightness_value_source_id": self._brightness_vs_id,
"processing": self._is_running, "processing": self._is_running,
"fps_actual": metrics.fps_actual if self._is_running else None, "fps_actual": metrics.fps_actual if self._is_running else None,
"fps_potential": metrics.fps_potential 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_out: Optional[np.ndarray] = None
_bright_n = 0 _bright_n = 0
def _cached_brightness(colors_in, dev_info): def _cached_brightness(colors_in, brightness: int):
nonlocal _bright_n, _bright_u16, _bright_out nonlocal _bright_n, _bright_u16, _bright_out
if not dev_info or dev_info.software_brightness >= 255: if brightness >= 255:
return colors_in return colors_in
_dn = len(colors_in) _dn = len(colors_in)
if _dn != _bright_n: if _dn != _bright_n:
@@ -402,11 +451,20 @@ class WledTargetProcessor(TargetProcessor):
_bright_u16 = np.empty((_dn, 3), dtype=np.uint16) _bright_u16 = np.empty((_dn, 3), dtype=np.uint16)
_bright_out = np.empty((_dn, 3), dtype=np.uint8) _bright_out = np.empty((_dn, 3), dtype=np.uint8)
np.copyto(_bright_u16, colors_in, casting='unsafe') np.copyto(_bright_u16, colors_in, casting='unsafe')
_bright_u16 *= dev_info.software_brightness _bright_u16 *= brightness
_bright_u16 >>= 8 _bright_u16 >>= 8
np.copyto(_bright_out, _bright_u16, casting='unsafe') np.copyto(_bright_out, _bright_u16, casting='unsafe')
return _bright_out 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 SKIP_REPOLL = 0.005 # 5 ms
# --- Timing diagnostics --- # --- Timing diagnostics ---
@@ -471,7 +529,8 @@ class WledTargetProcessor(TargetProcessor):
if not self._is_running or self._led_client is None: if not self._is_running or self._led_client is None:
break break
send_colors = _cached_brightness( 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: if self._led_client.supports_fast_send:
self._led_client.send_pixels_fast(send_colors) self._led_client.send_pixels_fast(send_colors)
@@ -495,7 +554,7 @@ class WledTargetProcessor(TargetProcessor):
# Fit to device LED count and apply brightness # Fit to device LED count and apply brightness
device_colors = self._fit_to_device(frame, _total_leds) 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 # Send to LED device
if not self._is_running or self._led_client is None: if not self._is_running or self._led_client is None:

View File

@@ -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.picture_target_store import PictureTargetStore
from wled_controller.storage.color_strip_store import ColorStripStore from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.audio_source_store import AudioSourceStore 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.storage.profile_store import ProfileStore
from wled_controller.core.profiles.profile_engine import ProfileEngine from wled_controller.core.profiles.profile_engine import ProfileEngine
from wled_controller.utils import setup_logging, get_logger 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) pattern_template_store = PatternTemplateStore(config.storage.pattern_templates_file)
color_strip_store = ColorStripStore(config.storage.color_strip_sources_file) color_strip_store = ColorStripStore(config.storage.color_strip_sources_file)
audio_source_store = AudioSourceStore(config.storage.audio_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) profile_store = ProfileStore(config.storage.profiles_file)
# Migrate embedded audio config from CSS entities to audio sources # Migrate embedded audio config from CSS entities to audio sources
@@ -57,6 +59,7 @@ processor_manager = ProcessorManager(
device_store=device_store, device_store=device_store,
color_strip_store=color_strip_store, color_strip_store=color_strip_store,
audio_source_store=audio_source_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, picture_target_store=picture_target_store,
color_strip_store=color_strip_store, color_strip_store=color_strip_store,
audio_source_store=audio_source_store, audio_source_store=audio_source_store,
value_source_store=value_source_store,
profile_store=profile_store, profile_store=profile_store,
profile_engine=profile_engine, profile_engine=profile_engine,
) )

View File

@@ -108,6 +108,12 @@ import {
editAudioSource, deleteAudioSource, onAudioSourceTypeChange, editAudioSource, deleteAudioSource, onAudioSourceTypeChange,
} from './features/audio-sources.js'; } from './features/audio-sources.js';
// Layer 5: value sources
import {
showValueSourceModal, closeValueSourceModal, saveValueSource,
editValueSource, deleteValueSource, onValueSourceTypeChange,
} from './features/value-sources.js';
// Layer 5: calibration // Layer 5: calibration
import { import {
showCalibration, closeCalibrationModal, forceCloseCalibrationModal, saveCalibration, showCalibration, closeCalibrationModal, forceCloseCalibrationModal, saveCalibration,
@@ -317,6 +323,14 @@ Object.assign(window, {
deleteAudioSource, deleteAudioSource,
onAudioSourceTypeChange, onAudioSourceTypeChange,
// value sources
showValueSourceModal,
closeValueSourceModal,
saveValueSource,
editValueSource,
deleteValueSource,
onValueSourceTypeChange,
// calibration // calibration
showCalibration, showCalibration,
closeCalibrationModal, closeCalibrationModal,

View File

@@ -169,6 +169,10 @@ export const PATTERN_RECT_BORDERS = [
export let _cachedAudioSources = []; export let _cachedAudioSources = [];
export function set_cachedAudioSources(v) { _cachedAudioSources = v; } export function set_cachedAudioSources(v) { _cachedAudioSources = v; }
// Value sources
export let _cachedValueSources = [];
export function set_cachedValueSources(v) { _cachedValueSources = v; }
// Profiles // Profiles
export let _profilesCache = null; export let _profilesCache = null;
export function set_profilesCache(v) { _profilesCache = v; } export function set_profilesCache(v) { _profilesCache = v; }

View File

@@ -19,6 +19,7 @@ import {
_currentTestPPTemplateId, set_currentTestPPTemplateId, _currentTestPPTemplateId, set_currentTestPPTemplateId,
_lastValidatedImageSource, set_lastValidatedImageSource, _lastValidatedImageSource, set_lastValidatedImageSource,
_cachedAudioSources, set_cachedAudioSources, _cachedAudioSources, set_cachedAudioSources,
_cachedValueSources, set_cachedValueSources,
apiKey, apiKey,
} from '../core/state.js'; } from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.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 { showToast, showConfirm, openLightbox, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner } from '../core/ui.js';
import { openDisplayPicker, formatDisplayLabel } from './displays.js'; import { openDisplayPicker, formatDisplayLabel } from './displays.js';
import { CardSection } from '../core/card-sections.js'; import { CardSection } from '../core/card-sections.js';
import { createValueSourceCard } from './value-sources.js';
// ── Card section instances ── // ── Card section instances ──
const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')" }); 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 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 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 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 // Re-render picture sources when language changes
document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); }); document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); });
@@ -447,12 +450,13 @@ export async function deleteTemplate(templateId) {
export async function loadPictureSources() { export async function loadPictureSources() {
try { 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), _availableFilters.length === 0 ? fetchWithAuth('/filters') : Promise.resolve(null),
fetchWithAuth('/postprocessing-templates'), fetchWithAuth('/postprocessing-templates'),
fetchWithAuth('/capture-templates'), fetchWithAuth('/capture-templates'),
fetchWithAuth('/picture-sources'), fetchWithAuth('/picture-sources'),
fetchWithAuth('/audio-sources'), fetchWithAuth('/audio-sources'),
fetchWithAuth('/value-sources'),
]); ]);
if (filtersResp && filtersResp.ok) { if (filtersResp && filtersResp.ok) {
@@ -471,6 +475,10 @@ export async function loadPictureSources() {
const ad = await audioResp.json(); const ad = await audioResp.json();
set_cachedAudioSources(ad.sources || []); 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}`); if (!streamsResp.ok) throw new Error(`Failed to load streams: ${streamsResp.status}`);
const data = await streamsResp.json(); const data = await streamsResp.json();
set_cachedStreams(data.streams || []); set_cachedStreams(data.streams || []);
@@ -621,6 +629,7 @@ function renderPictureSourcesList(streams) {
{ key: 'static_image', icon: '🖼️', titleKey: 'streams.group.static_image', count: staticImageStreams.length }, { key: 'static_image', icon: '🖼️', titleKey: 'streams.group.static_image', count: staticImageStreams.length },
{ key: 'processed', icon: '🎨', titleKey: 'streams.group.processed', count: processedStreams.length }, { key: 'processed', icon: '🎨', titleKey: 'streams.group.processed', count: processedStreams.length },
{ key: 'audio', icon: '🔊', titleKey: 'streams.group.audio', count: _cachedAudioSources.length }, { key: 'audio', icon: '🔊', titleKey: 'streams.group.audio', count: _cachedAudioSources.length },
{ key: 'value', icon: '🎚️', titleKey: 'streams.group.value', count: _cachedValueSources.length },
]; ];
const tabBar = `<div class="stream-tab-bar">${tabs.map(tab => const tabBar = `<div class="stream-tab-bar">${tabs.map(tab =>
@@ -677,6 +686,8 @@ function renderPictureSourcesList(streams) {
panelContent = panelContent =
csAudioMulti.render(multichannelSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) }))) + csAudioMulti.render(multichannelSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) }))) +
csAudioMono.render(monoSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) }))); csAudioMono.render(monoSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) })));
} else if (tab.key === 'value') {
panelContent = csValueSources.render(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) })));
} else { } else {
panelContent = csStaticStreams.render(staticImageStreams.map(s => ({ key: s.id, html: renderStreamCard(s) }))); panelContent = csStaticStreams.render(staticImageStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
} }
@@ -685,7 +696,7 @@ function renderPictureSourcesList(streams) {
}).join(''); }).join('');
container.innerHTML = tabBar + panels; container.innerHTML = tabBar + panels;
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csAudioMulti, csAudioMono, csStaticStreams]); CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csAudioMulti, csAudioMono, csStaticStreams, csValueSources]);
} }
export function onStreamTypeChange() { export function onStreamTypeChange() {

View File

@@ -7,6 +7,7 @@ import {
_targetEditorDevices, set_targetEditorDevices, _targetEditorDevices, set_targetEditorDevices,
_deviceBrightnessCache, _deviceBrightnessCache,
kcWebSockets, kcWebSockets,
_cachedValueSources, set_cachedValueSources,
} from '../core/state.js'; } from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
@@ -112,6 +113,7 @@ class TargetEditorModal extends Modal {
name: document.getElementById('target-editor-name').value, name: document.getElementById('target-editor-name').value,
device: document.getElementById('target-editor-device').value, device: document.getElementById('target-editor-device').value,
css_source: document.getElementById('target-editor-css-source').value, css_source: document.getElementById('target-editor-css-source').value,
brightness_vs: document.getElementById('target-editor-brightness-vs').value,
fps: document.getElementById('target-editor-fps').value, fps: document.getElementById('target-editor-fps').value,
keepalive_interval: document.getElementById('target-editor-keepalive-interval').value, keepalive_interval: document.getElementById('target-editor-keepalive-interval').value,
}; };
@@ -177,16 +179,32 @@ function _populateCssDropdown(selectedId = '') {
).join(''); ).join('');
} }
function _populateBrightnessVsDropdown(selectedId = '') {
const select = document.getElementById('target-editor-brightness-vs');
let html = `<option value="">${t('targets.brightness_vs.none')}</option>`;
_cachedValueSources.forEach(vs => {
const typeIcons = { static: '📊', animated: '🔄', audio: '🎵' };
const icon = typeIcons[vs.source_type] || '🎚️';
html += `<option value="${vs.id}"${vs.id === selectedId ? ' selected' : ''}>${icon} ${escapeHtml(vs.name)}</option>`;
});
select.innerHTML = html;
}
export async function showTargetEditor(targetId = null, cloneData = null) { export async function showTargetEditor(targetId = null, cloneData = null) {
try { try {
// Load devices and CSS sources for dropdowns // Load devices, CSS sources, and value sources for dropdowns
const [devicesResp, cssResp] = await Promise.all([ const [devicesResp, cssResp, vsResp] = await Promise.all([
fetch(`${API_BASE}/devices`, { headers: getHeaders() }), fetch(`${API_BASE}/devices`, { headers: getHeaders() }),
fetchWithAuth('/color-strip-sources'), fetchWithAuth('/color-strip-sources'),
fetchWithAuth('/value-sources'),
]); ]);
const devices = devicesResp.ok ? (await devicesResp.json()).devices || [] : []; const devices = devicesResp.ok ? (await devicesResp.json()).devices || [] : [];
const cssSources = cssResp.ok ? (await cssResp.json()).sources || [] : []; const cssSources = cssResp.ok ? (await cssResp.json()).sources || [] : [];
if (vsResp.ok) {
const vsData = await vsResp.json();
set_cachedValueSources(vsData.sources || []);
}
set_targetEditorDevices(devices); set_targetEditorDevices(devices);
_editorCssSources = cssSources; _editorCssSources = cssSources;
@@ -220,6 +238,7 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
document.getElementById('target-editor-title').textContent = t('targets.edit'); document.getElementById('target-editor-title').textContent = t('targets.edit');
_populateCssDropdown(target.color_strip_source_id || ''); _populateCssDropdown(target.color_strip_source_id || '');
_populateBrightnessVsDropdown(target.brightness_value_source_id || '');
} else if (cloneData) { } else if (cloneData) {
// Cloning — create mode but pre-filled from clone data // Cloning — create mode but pre-filled from clone data
document.getElementById('target-editor-id').value = ''; document.getElementById('target-editor-id').value = '';
@@ -233,6 +252,7 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
document.getElementById('target-editor-title').textContent = t('targets.add'); document.getElementById('target-editor-title').textContent = t('targets.add');
_populateCssDropdown(cloneData.color_strip_source_id || ''); _populateCssDropdown(cloneData.color_strip_source_id || '');
_populateBrightnessVsDropdown(cloneData.brightness_value_source_id || '');
} else { } else {
// Creating new target // Creating new target
document.getElementById('target-editor-id').value = ''; document.getElementById('target-editor-id').value = '';
@@ -244,6 +264,7 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
document.getElementById('target-editor-title').textContent = t('targets.add'); document.getElementById('target-editor-title').textContent = t('targets.add');
_populateCssDropdown(''); _populateCssDropdown('');
_populateBrightnessVsDropdown('');
} }
// Auto-name generation // Auto-name generation
@@ -296,10 +317,13 @@ export async function saveTargetEditor() {
const fps = parseInt(document.getElementById('target-editor-fps').value) || 30; const fps = parseInt(document.getElementById('target-editor-fps').value) || 30;
const colorStripSourceId = document.getElementById('target-editor-css-source').value; const colorStripSourceId = document.getElementById('target-editor-css-source').value;
const brightnessVsId = document.getElementById('target-editor-brightness-vs').value;
const payload = { const payload = {
name, name,
device_id: deviceId, device_id: deviceId,
color_strip_source_id: colorStripSourceId, color_strip_source_id: colorStripSourceId,
brightness_value_source_id: brightnessVsId,
fps, fps,
keepalive_interval: standbyInterval, keepalive_interval: standbyInterval,
}; };

View File

@@ -0,0 +1,234 @@
/**
* Value Sources — CRUD for scalar value sources (static, animated, audio).
*
* Value sources produce a float 0.0-1.0 used for dynamic brightness control
* on LED targets. Three subtypes: static (constant), animated (waveform),
* audio (audio-reactive).
*
* Card rendering is handled by streams.js (Value tab).
* This module manages the editor modal and API operations.
*/
import { _cachedValueSources, set_cachedValueSources, _cachedAudioSources } from '../core/state.js';
import { fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js';
import { loadPictureSources } from './streams.js';
const valueSourceModal = new Modal('value-source-modal');
// ── Modal ─────────────────────────────────────────────────────
export async function showValueSourceModal(editData) {
const isEdit = !!editData;
const titleKey = isEdit ? 'value_source.edit' : 'value_source.add';
document.getElementById('value-source-modal-title').textContent = t(titleKey);
document.getElementById('value-source-id').value = isEdit ? editData.id : '';
document.getElementById('value-source-error').style.display = 'none';
const typeSelect = document.getElementById('value-source-type');
typeSelect.disabled = isEdit;
if (isEdit) {
document.getElementById('value-source-name').value = editData.name || '';
document.getElementById('value-source-description').value = editData.description || '';
typeSelect.value = editData.source_type || 'static';
onValueSourceTypeChange();
if (editData.source_type === 'static') {
_setSlider('value-source-value', editData.value ?? 1.0);
} else if (editData.source_type === 'animated') {
document.getElementById('value-source-waveform').value = editData.waveform || 'sine';
_setSlider('value-source-speed', editData.speed ?? 10);
_setSlider('value-source-min-value', editData.min_value ?? 0);
_setSlider('value-source-max-value', editData.max_value ?? 1);
} else if (editData.source_type === 'audio') {
_populateAudioSourceDropdown(editData.audio_source_id || '');
document.getElementById('value-source-mode').value = editData.mode || 'rms';
_setSlider('value-source-sensitivity', editData.sensitivity ?? 1.0);
_setSlider('value-source-smoothing', editData.smoothing ?? 0.3);
}
} else {
document.getElementById('value-source-name').value = '';
document.getElementById('value-source-description').value = '';
typeSelect.value = 'static';
onValueSourceTypeChange();
_setSlider('value-source-value', 1.0);
_setSlider('value-source-speed', 10);
_setSlider('value-source-min-value', 0);
_setSlider('value-source-max-value', 1);
document.getElementById('value-source-waveform').value = 'sine';
_populateAudioSourceDropdown('');
document.getElementById('value-source-mode').value = 'rms';
_setSlider('value-source-sensitivity', 1.0);
_setSlider('value-source-smoothing', 0.3);
}
valueSourceModal.open();
}
export function closeValueSourceModal() {
valueSourceModal.forceClose();
}
export function onValueSourceTypeChange() {
const type = document.getElementById('value-source-type').value;
document.getElementById('value-source-static-section').style.display = type === 'static' ? '' : 'none';
document.getElementById('value-source-animated-section').style.display = type === 'animated' ? '' : 'none';
document.getElementById('value-source-audio-section').style.display = type === 'audio' ? '' : 'none';
// Populate audio dropdown when switching to audio type
if (type === 'audio') {
const select = document.getElementById('value-source-audio-source');
if (select && select.options.length === 0) {
_populateAudioSourceDropdown('');
}
}
}
// ── Save ──────────────────────────────────────────────────────
export async function saveValueSource() {
const id = document.getElementById('value-source-id').value;
const name = document.getElementById('value-source-name').value.trim();
const sourceType = document.getElementById('value-source-type').value;
const description = document.getElementById('value-source-description').value.trim() || null;
const errorEl = document.getElementById('value-source-error');
if (!name) {
errorEl.textContent = t('value_source.error.name_required');
errorEl.style.display = '';
return;
}
const payload = { name, source_type: sourceType, description };
if (sourceType === 'static') {
payload.value = parseFloat(document.getElementById('value-source-value').value);
} else if (sourceType === 'animated') {
payload.waveform = document.getElementById('value-source-waveform').value;
payload.speed = parseFloat(document.getElementById('value-source-speed').value);
payload.min_value = parseFloat(document.getElementById('value-source-min-value').value);
payload.max_value = parseFloat(document.getElementById('value-source-max-value').value);
} else if (sourceType === 'audio') {
payload.audio_source_id = document.getElementById('value-source-audio-source').value;
payload.mode = document.getElementById('value-source-mode').value;
payload.sensitivity = parseFloat(document.getElementById('value-source-sensitivity').value);
payload.smoothing = parseFloat(document.getElementById('value-source-smoothing').value);
}
try {
const method = id ? 'PUT' : 'POST';
const url = id ? `/value-sources/${id}` : '/value-sources';
const resp = await fetchWithAuth(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t(id ? 'value_source.updated' : 'value_source.created'), 'success');
valueSourceModal.forceClose();
await loadPictureSources();
} catch (e) {
errorEl.textContent = e.message;
errorEl.style.display = '';
}
}
// ── Edit ──────────────────────────────────────────────────────
export async function editValueSource(sourceId) {
try {
const resp = await fetchWithAuth(`/value-sources/${sourceId}`);
if (!resp.ok) throw new Error('fetch failed');
const data = await resp.json();
await showValueSourceModal(data);
} catch (e) {
showToast(e.message, 'error');
}
}
// ── Delete ────────────────────────────────────────────────────
export async function deleteValueSource(sourceId) {
const confirmed = await showConfirm(t('value_source.delete.confirm'));
if (!confirmed) return;
try {
const resp = await fetchWithAuth(`/value-sources/${sourceId}`, { method: 'DELETE' });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t('value_source.deleted'), 'success');
await loadPictureSources();
} catch (e) {
showToast(e.message, 'error');
}
}
// ── Card rendering (used by streams.js) ───────────────────────
export function createValueSourceCard(src) {
const typeIcons = { static: '📊', animated: '🔄', audio: '🎵' };
const icon = typeIcons[src.source_type] || '🎚️';
let propsHtml = '';
if (src.source_type === 'static') {
propsHtml = `<span class="stream-card-prop">${t('value_source.type.static')}: ${src.value ?? 1.0}</span>`;
} else if (src.source_type === 'animated') {
const waveLabel = src.waveform || 'sine';
propsHtml = `
<span class="stream-card-prop">${escapeHtml(waveLabel)}</span>
<span class="stream-card-prop">${src.speed ?? 10} cpm</span>
<span class="stream-card-prop">${src.min_value ?? 0}${src.max_value ?? 1}</span>
`;
} else if (src.source_type === 'audio') {
const audioSrc = _cachedAudioSources.find(a => a.id === src.audio_source_id);
const audioName = audioSrc ? audioSrc.name : (src.audio_source_id || '-');
const modeLabel = src.mode || 'rms';
propsHtml = `
<span class="stream-card-prop" title="${escapeHtml(t('value_source.audio_source'))}">${escapeHtml(audioName)}</span>
<span class="stream-card-prop">${modeLabel.toUpperCase()}</span>
`;
}
return `
<div class="template-card" data-id="${src.id}">
<button class="card-remove-btn" onclick="deleteValueSource('${src.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="template-card-header">
<div class="template-name">${icon} ${escapeHtml(src.name)}</div>
</div>
<div class="stream-card-props">${propsHtml}</div>
${src.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(src.description)}</div>` : ''}
<div class="template-card-actions">
<button class="btn btn-icon btn-secondary" onclick="editValueSource('${src.id}')" title="${t('common.edit')}">✏️</button>
</div>
</div>
`;
}
// ── Helpers ───────────────────────────────────────────────────
function _setSlider(id, value) {
const slider = document.getElementById(id);
if (slider) {
slider.value = value;
const display = document.getElementById(id + '-display');
if (display) display.textContent = value;
}
}
function _populateAudioSourceDropdown(selectedId) {
const select = document.getElementById('value-source-audio-source');
if (!select) return;
const mono = _cachedAudioSources.filter(s => s.source_type === 'mono');
select.innerHTML = mono.map(s =>
`<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
}

View File

@@ -760,5 +760,54 @@
"audio_source.updated": "Audio source updated", "audio_source.updated": "Audio source updated",
"audio_source.deleted": "Audio source deleted", "audio_source.deleted": "Audio source deleted",
"audio_source.delete.confirm": "Are you sure you want to delete this audio source?", "audio_source.delete.confirm": "Are you sure you want to delete this audio source?",
"audio_source.error.name_required": "Please enter a name" "audio_source.error.name_required": "Please enter a name",
"streams.group.value": "Value Sources",
"value_source.group.title": "🎚️ Value Sources",
"value_source.add": "Add Value Source",
"value_source.edit": "Edit Value Source",
"value_source.name": "Name:",
"value_source.name.placeholder": "Brightness Pulse",
"value_source.name.hint": "A descriptive name for this value source",
"value_source.type": "Type:",
"value_source.type.hint": "Static outputs a constant value. Animated cycles through a waveform. Audio reacts to sound input.",
"value_source.type.static": "Static",
"value_source.type.animated": "Animated",
"value_source.type.audio": "Audio",
"value_source.value": "Value:",
"value_source.value.hint": "Constant output value (0.0 = off, 1.0 = full brightness)",
"value_source.waveform": "Waveform:",
"value_source.waveform.hint": "Shape of the brightness animation cycle",
"value_source.waveform.sine": "Sine",
"value_source.waveform.triangle": "Triangle",
"value_source.waveform.square": "Square",
"value_source.waveform.sawtooth": "Sawtooth",
"value_source.speed": "Speed (cpm):",
"value_source.speed.hint": "Cycles per minute — how fast the waveform repeats (1 = very slow, 120 = very fast)",
"value_source.min_value": "Min Value:",
"value_source.min_value.hint": "Minimum output of the waveform cycle",
"value_source.max_value": "Max Value:",
"value_source.max_value.hint": "Maximum output of the waveform cycle",
"value_source.audio_source": "Audio Source:",
"value_source.audio_source.hint": "Mono audio source to read audio levels from",
"value_source.mode": "Mode:",
"value_source.mode.hint": "RMS measures average volume. Peak tracks loudest moments. Beat triggers on rhythm.",
"value_source.mode.rms": "RMS (Volume)",
"value_source.mode.peak": "Peak",
"value_source.mode.beat": "Beat",
"value_source.sensitivity": "Sensitivity:",
"value_source.sensitivity.hint": "Gain multiplier for the audio signal (higher = more reactive)",
"value_source.smoothing": "Smoothing:",
"value_source.smoothing.hint": "Temporal smoothing (0 = instant response, 1 = very smooth/slow)",
"value_source.description": "Description (optional):",
"value_source.description.placeholder": "Describe this value source...",
"value_source.description.hint": "Optional notes about this value source",
"value_source.created": "Value source created",
"value_source.updated": "Value source updated",
"value_source.deleted": "Value source deleted",
"value_source.delete.confirm": "Are you sure you want to delete this value source?",
"value_source.error.name_required": "Please enter a name",
"targets.brightness_vs": "Brightness Source:",
"targets.brightness_vs.hint": "Optional value source that dynamically controls brightness each frame (overrides device brightness)",
"targets.brightness_vs.none": "None (device brightness)"
} }

View File

@@ -760,5 +760,54 @@
"audio_source.updated": "Аудиоисточник обновлён", "audio_source.updated": "Аудиоисточник обновлён",
"audio_source.deleted": "Аудиоисточник удалён", "audio_source.deleted": "Аудиоисточник удалён",
"audio_source.delete.confirm": "Удалить этот аудиоисточник?", "audio_source.delete.confirm": "Удалить этот аудиоисточник?",
"audio_source.error.name_required": "Введите название" "audio_source.error.name_required": "Введите название",
"streams.group.value": "Источники значений",
"value_source.group.title": "🎚️ Источники значений",
"value_source.add": "Добавить источник значений",
"value_source.edit": "Редактировать источник значений",
"value_source.name": "Название:",
"value_source.name.placeholder": "Пульс яркости",
"value_source.name.hint": "Описательное имя для этого источника значений",
"value_source.type": "Тип:",
"value_source.type.hint": "Статический выдаёт постоянное значение. Анимированный циклически меняет форму волны. Аудио реагирует на звук.",
"value_source.type.static": "Статический",
"value_source.type.animated": "Анимированный",
"value_source.type.audio": "Аудио",
"value_source.value": "Значение:",
"value_source.value.hint": "Постоянное выходное значение (0.0 = выкл, 1.0 = полная яркость)",
"value_source.waveform": "Форма волны:",
"value_source.waveform.hint": "Форма цикла анимации яркости",
"value_source.waveform.sine": "Синус",
"value_source.waveform.triangle": "Треугольник",
"value_source.waveform.square": "Прямоугольник",
"value_source.waveform.sawtooth": "Пила",
"value_source.speed": "Скорость (цикл/мин):",
"value_source.speed.hint": "Циклов в минуту — как быстро повторяется волна (1 = очень медленно, 120 = очень быстро)",
"value_source.min_value": "Мин. значение:",
"value_source.min_value.hint": "Минимальный выход цикла волны",
"value_source.max_value": "Макс. значение:",
"value_source.max_value.hint": "Максимальный выход цикла волны",
"value_source.audio_source": "Аудиоисточник:",
"value_source.audio_source.hint": "Моно-аудиоисточник для считывания уровня звука",
"value_source.mode": "Режим:",
"value_source.mode.hint": "RMS измеряет среднюю громкость. Пик отслеживает самые громкие моменты. Бит реагирует на ритм.",
"value_source.mode.rms": "RMS (Громкость)",
"value_source.mode.peak": "Пик",
"value_source.mode.beat": "Бит",
"value_source.sensitivity": "Чувствительность:",
"value_source.sensitivity.hint": "Множитель усиления аудиосигнала (выше = более реактивный)",
"value_source.smoothing": "Сглаживание:",
"value_source.smoothing.hint": "Временное сглаживание (0 = мгновенный отклик, 1 = очень плавный/медленный)",
"value_source.description": "Описание (необязательно):",
"value_source.description.placeholder": "Опишите этот источник значений...",
"value_source.description.hint": "Необязательные заметки об этом источнике значений",
"value_source.created": "Источник значений создан",
"value_source.updated": "Источник значений обновлён",
"value_source.deleted": "Источник значений удалён",
"value_source.delete.confirm": "Удалить этот источник значений?",
"value_source.error.name_required": "Введите название",
"targets.brightness_vs": "Источник яркости:",
"targets.brightness_vs.hint": "Необязательный источник значений для динамического управления яркостью каждый кадр (переопределяет яркость устройства)",
"targets.brightness_vs.none": "Нет (яркость устройства)"
} }

View File

@@ -102,6 +102,7 @@ class PictureTargetStore:
target_type: str, target_type: str,
device_id: str = "", device_id: str = "",
color_strip_source_id: str = "", color_strip_source_id: str = "",
brightness_value_source_id: str = "",
fps: int = 30, fps: int = 30,
keepalive_interval: float = 1.0, keepalive_interval: float = 1.0,
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL, state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
@@ -132,6 +133,7 @@ class PictureTargetStore:
target_type="led", target_type="led",
device_id=device_id, device_id=device_id,
color_strip_source_id=color_strip_source_id, color_strip_source_id=color_strip_source_id,
brightness_value_source_id=brightness_value_source_id,
fps=fps, fps=fps,
keepalive_interval=keepalive_interval, keepalive_interval=keepalive_interval,
state_check_interval=state_check_interval, state_check_interval=state_check_interval,
@@ -165,6 +167,7 @@ class PictureTargetStore:
name: Optional[str] = None, name: Optional[str] = None,
device_id: Optional[str] = None, device_id: Optional[str] = None,
color_strip_source_id: Optional[str] = None, color_strip_source_id: Optional[str] = None,
brightness_value_source_id: Optional[str] = None,
fps: Optional[int] = None, fps: Optional[int] = None,
keepalive_interval: Optional[float] = None, keepalive_interval: Optional[float] = None,
state_check_interval: Optional[int] = None, state_check_interval: Optional[int] = None,
@@ -191,6 +194,7 @@ class PictureTargetStore:
name=name, name=name,
device_id=device_id, device_id=device_id,
color_strip_source_id=color_strip_source_id, color_strip_source_id=color_strip_source_id,
brightness_value_source_id=brightness_value_source_id,
fps=fps, fps=fps,
keepalive_interval=keepalive_interval, keepalive_interval=keepalive_interval,
state_check_interval=state_check_interval, state_check_interval=state_check_interval,

View File

@@ -0,0 +1,154 @@
"""Value source data model with inheritance-based source types.
A ValueSource produces a scalar float (0.01.0) that can drive target
parameters like brightness. Three types:
StaticValueSource — constant float value
AnimatedValueSource — periodic waveform (sine, triangle, square, sawtooth)
AudioValueSource — audio-reactive scalar (RMS, peak, beat detection)
"""
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
@dataclass
class ValueSource:
"""Base class for value source configurations."""
id: str
name: str
source_type: str # "static" | "animated" | "audio"
created_at: datetime
updated_at: datetime
description: Optional[str] = None
def to_dict(self) -> dict:
"""Convert source to dictionary. Subclasses extend this."""
return {
"id": self.id,
"name": self.name,
"source_type": self.source_type,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
"description": self.description,
# Subclass fields default to None for forward compat
"value": None,
"waveform": None,
"speed": None,
"min_value": None,
"max_value": None,
"audio_source_id": None,
"mode": None,
"sensitivity": None,
"smoothing": None,
}
@staticmethod
def from_dict(data: dict) -> "ValueSource":
"""Factory: dispatch to the correct subclass based on source_type."""
source_type: str = data.get("source_type", "static") or "static"
sid: str = data["id"]
name: str = data["name"]
description: str | None = data.get("description")
raw_created = data.get("created_at")
created_at: datetime = (
datetime.fromisoformat(raw_created)
if isinstance(raw_created, str)
else raw_created if isinstance(raw_created, datetime)
else datetime.utcnow()
)
raw_updated = data.get("updated_at")
updated_at: datetime = (
datetime.fromisoformat(raw_updated)
if isinstance(raw_updated, str)
else raw_updated if isinstance(raw_updated, datetime)
else datetime.utcnow()
)
if source_type == "animated":
return AnimatedValueSource(
id=sid, name=name, source_type="animated",
created_at=created_at, updated_at=updated_at, description=description,
waveform=data.get("waveform") or "sine",
speed=float(data.get("speed") or 10.0),
min_value=float(data.get("min_value") or 0.0),
max_value=float(data["max_value"]) if data.get("max_value") is not None else 1.0,
)
if source_type == "audio":
return AudioValueSource(
id=sid, name=name, source_type="audio",
created_at=created_at, updated_at=updated_at, description=description,
audio_source_id=data.get("audio_source_id") or "",
mode=data.get("mode") or "rms",
sensitivity=float(data.get("sensitivity") or 1.0),
smoothing=float(data.get("smoothing") or 0.3),
)
# Default: "static" type
return StaticValueSource(
id=sid, name=name, source_type="static",
created_at=created_at, updated_at=updated_at, description=description,
value=float(data["value"]) if data.get("value") is not None else 1.0,
)
@dataclass
class StaticValueSource(ValueSource):
"""Value source that outputs a constant float.
Useful as a simple per-target brightness override.
"""
value: float = 1.0 # 0.01.0
def to_dict(self) -> dict:
d = super().to_dict()
d["value"] = self.value
return d
@dataclass
class AnimatedValueSource(ValueSource):
"""Value source that cycles through a periodic waveform.
Produces a smooth animation between min_value and max_value
at the configured speed (cycles per minute).
"""
waveform: str = "sine" # sine | triangle | square | sawtooth
speed: float = 10.0 # cycles per minute (1.0120.0)
min_value: float = 0.0 # minimum output (0.01.0)
max_value: float = 1.0 # maximum output (0.01.0)
def to_dict(self) -> dict:
d = super().to_dict()
d["waveform"] = self.waveform
d["speed"] = self.speed
d["min_value"] = self.min_value
d["max_value"] = self.max_value
return d
@dataclass
class AudioValueSource(ValueSource):
"""Value source driven by audio input.
Converts audio analysis (RMS level, peak, or beat detection)
into a scalar value for brightness modulation.
"""
audio_source_id: str = "" # references a MonoAudioSource
mode: str = "rms" # rms | peak | beat
sensitivity: float = 1.0 # gain multiplier (0.15.0)
smoothing: float = 0.3 # temporal smoothing (0.01.0)
def to_dict(self) -> dict:
d = super().to_dict()
d["audio_source_id"] = self.audio_source_id
d["mode"] = self.mode
d["sensitivity"] = self.sensitivity
d["smoothing"] = self.smoothing
return d

View File

@@ -0,0 +1,213 @@
"""Value source storage using JSON files."""
import json
import uuid
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
from wled_controller.storage.value_source import (
AnimatedValueSource,
AudioValueSource,
StaticValueSource,
ValueSource,
)
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class ValueSourceStore:
"""Persistent storage for value sources."""
def __init__(self, file_path: str):
self.file_path = Path(file_path)
self._sources: Dict[str, ValueSource] = {}
self._load()
def _load(self) -> None:
if not self.file_path.exists():
logger.info("Value source store file not found — starting empty")
return
try:
with open(self.file_path, "r", encoding="utf-8") as f:
data = json.load(f)
sources_data = data.get("value_sources", {})
loaded = 0
for source_id, source_dict in sources_data.items():
try:
source = ValueSource.from_dict(source_dict)
self._sources[source_id] = source
loaded += 1
except Exception as e:
logger.error(
f"Failed to load value source {source_id}: {e}",
exc_info=True,
)
if loaded > 0:
logger.info(f"Loaded {loaded} value sources from storage")
except Exception as e:
logger.error(f"Failed to load value sources from {self.file_path}: {e}")
raise
logger.info(f"Value source store initialized with {len(self._sources)} sources")
def _save(self) -> None:
try:
self.file_path.parent.mkdir(parents=True, exist_ok=True)
sources_dict = {
sid: source.to_dict()
for sid, source in self._sources.items()
}
data = {
"version": "1.0.0",
"value_sources": sources_dict,
}
with open(self.file_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
except Exception as e:
logger.error(f"Failed to save value sources to {self.file_path}: {e}")
raise
# ── CRUD ─────────────────────────────────────────────────────────
def get_all_sources(self) -> List[ValueSource]:
return list(self._sources.values())
def get_source(self, source_id: str) -> ValueSource:
if source_id not in self._sources:
raise ValueError(f"Value source not found: {source_id}")
return self._sources[source_id]
def create_source(
self,
name: str,
source_type: str,
value: Optional[float] = None,
waveform: Optional[str] = None,
speed: Optional[float] = None,
min_value: Optional[float] = None,
max_value: Optional[float] = None,
audio_source_id: Optional[str] = None,
mode: Optional[str] = None,
sensitivity: Optional[float] = None,
smoothing: Optional[float] = None,
description: Optional[str] = None,
) -> ValueSource:
if not name or not name.strip():
raise ValueError("Name is required")
if source_type not in ("static", "animated", "audio"):
raise ValueError(f"Invalid source type: {source_type}")
for source in self._sources.values():
if source.name == name:
raise ValueError(f"Value source with name '{name}' already exists")
sid = f"vs_{uuid.uuid4().hex[:8]}"
now = datetime.utcnow()
if source_type == "static":
source: ValueSource = StaticValueSource(
id=sid, name=name, source_type="static",
created_at=now, updated_at=now, description=description,
value=value if value is not None else 1.0,
)
elif source_type == "animated":
source = AnimatedValueSource(
id=sid, name=name, source_type="animated",
created_at=now, updated_at=now, description=description,
waveform=waveform or "sine",
speed=speed if speed is not None else 10.0,
min_value=min_value if min_value is not None else 0.0,
max_value=max_value if max_value is not None else 1.0,
)
elif source_type == "audio":
source = AudioValueSource(
id=sid, name=name, source_type="audio",
created_at=now, updated_at=now, description=description,
audio_source_id=audio_source_id or "",
mode=mode or "rms",
sensitivity=sensitivity if sensitivity is not None else 1.0,
smoothing=smoothing if smoothing is not None else 0.3,
)
self._sources[sid] = source
self._save()
logger.info(f"Created value source: {name} ({sid}, type={source_type})")
return source
def update_source(
self,
source_id: str,
name: Optional[str] = None,
value: Optional[float] = None,
waveform: Optional[str] = None,
speed: Optional[float] = None,
min_value: Optional[float] = None,
max_value: Optional[float] = None,
audio_source_id: Optional[str] = None,
mode: Optional[str] = None,
sensitivity: Optional[float] = None,
smoothing: Optional[float] = None,
description: Optional[str] = None,
) -> ValueSource:
if source_id not in self._sources:
raise ValueError(f"Value source not found: {source_id}")
source = self._sources[source_id]
if name is not None:
for other in self._sources.values():
if other.id != source_id and other.name == name:
raise ValueError(f"Value source with name '{name}' already exists")
source.name = name
if description is not None:
source.description = description
if isinstance(source, StaticValueSource):
if value is not None:
source.value = value
elif isinstance(source, AnimatedValueSource):
if waveform is not None:
source.waveform = waveform
if speed is not None:
source.speed = speed
if min_value is not None:
source.min_value = min_value
if max_value is not None:
source.max_value = max_value
elif isinstance(source, AudioValueSource):
if audio_source_id is not None:
source.audio_source_id = audio_source_id
if mode is not None:
source.mode = mode
if sensitivity is not None:
source.sensitivity = sensitivity
if smoothing is not None:
source.smoothing = smoothing
source.updated_at = datetime.utcnow()
self._save()
logger.info(f"Updated value source: {source_id}")
return source
def delete_source(self, source_id: str) -> None:
if source_id not in self._sources:
raise ValueError(f"Value source not found: {source_id}")
del self._sources[source_id]
self._save()
logger.info(f"Deleted value source: {source_id}")

View File

@@ -15,6 +15,7 @@ class WledPictureTarget(PictureTarget):
device_id: str = "" device_id: str = ""
color_strip_source_id: str = "" color_strip_source_id: str = ""
brightness_value_source_id: str = ""
fps: int = 30 # target send FPS (1-90) fps: int = 30 # target send FPS (1-90)
keepalive_interval: float = 1.0 # seconds between keepalive sends when screen is static keepalive_interval: float = 1.0 # seconds between keepalive sends when screen is static
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL
@@ -29,11 +30,13 @@ class WledPictureTarget(PictureTarget):
fps=self.fps, fps=self.fps,
keepalive_interval=self.keepalive_interval, keepalive_interval=self.keepalive_interval,
state_check_interval=self.state_check_interval, state_check_interval=self.state_check_interval,
brightness_value_source_id=self.brightness_value_source_id,
) )
def sync_with_manager(self, manager, *, settings_changed: bool, def sync_with_manager(self, manager, *, settings_changed: bool,
css_changed: bool = False, css_changed: bool = False,
device_changed: bool = False) -> None: device_changed: bool = False,
brightness_vs_changed: bool = False) -> None:
"""Push changed fields to the processor manager.""" """Push changed fields to the processor manager."""
if settings_changed: if settings_changed:
manager.update_target_settings(self.id, { manager.update_target_settings(self.id, {
@@ -45,8 +48,11 @@ class WledPictureTarget(PictureTarget):
manager.update_target_css(self.id, self.color_strip_source_id) manager.update_target_css(self.id, self.color_strip_source_id)
if device_changed: if device_changed:
manager.update_target_device(self.id, self.device_id) manager.update_target_device(self.id, self.device_id)
if brightness_vs_changed:
manager.update_target_brightness_vs(self.id, self.brightness_value_source_id)
def update_fields(self, *, name=None, device_id=None, color_strip_source_id=None, def update_fields(self, *, name=None, device_id=None, color_strip_source_id=None,
brightness_value_source_id=None,
fps=None, keepalive_interval=None, state_check_interval=None, fps=None, keepalive_interval=None, state_check_interval=None,
description=None, **_kwargs) -> None: description=None, **_kwargs) -> None:
"""Apply mutable field updates for WLED targets.""" """Apply mutable field updates for WLED targets."""
@@ -55,6 +61,8 @@ class WledPictureTarget(PictureTarget):
self.device_id = device_id self.device_id = device_id
if color_strip_source_id is not None: if color_strip_source_id is not None:
self.color_strip_source_id = color_strip_source_id self.color_strip_source_id = color_strip_source_id
if brightness_value_source_id is not None:
self.brightness_value_source_id = brightness_value_source_id
if fps is not None: if fps is not None:
self.fps = fps self.fps = fps
if keepalive_interval is not None: if keepalive_interval is not None:
@@ -71,6 +79,7 @@ class WledPictureTarget(PictureTarget):
d = super().to_dict() d = super().to_dict()
d["device_id"] = self.device_id d["device_id"] = self.device_id
d["color_strip_source_id"] = self.color_strip_source_id d["color_strip_source_id"] = self.color_strip_source_id
d["brightness_value_source_id"] = self.brightness_value_source_id
d["fps"] = self.fps d["fps"] = self.fps
d["keepalive_interval"] = self.keepalive_interval d["keepalive_interval"] = self.keepalive_interval
d["state_check_interval"] = self.state_check_interval d["state_check_interval"] = self.state_check_interval
@@ -95,6 +104,7 @@ class WledPictureTarget(PictureTarget):
target_type="led", target_type="led",
device_id=data.get("device_id", ""), device_id=data.get("device_id", ""),
color_strip_source_id=css_id, color_strip_source_id=css_id,
brightness_value_source_id=data.get("brightness_value_source_id", ""),
fps=data.get("fps", 30), fps=data.get("fps", 30),
keepalive_interval=data.get("keepalive_interval", data.get("standby_interval", 1.0)), keepalive_interval=data.get("keepalive_interval", data.get("standby_interval", 1.0)),
state_check_interval=data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL), state_check_interval=data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL),

View File

@@ -118,6 +118,7 @@
{% include 'modals/pp-template.html' %} {% include 'modals/pp-template.html' %}
{% include 'modals/profile-editor.html' %} {% include 'modals/profile-editor.html' %}
{% include 'modals/audio-source-editor.html' %} {% include 'modals/audio-source-editor.html' %}
{% include 'modals/value-source-editor.html' %}
{% include 'partials/tutorial-overlay.html' %} {% include 'partials/tutorial-overlay.html' %}
{% include 'partials/image-lightbox.html' %} {% include 'partials/image-lightbox.html' %}

View File

@@ -33,6 +33,17 @@
<select id="target-editor-css-source"></select> <select id="target-editor-css-source"></select>
</div> </div>
<div class="form-group">
<div class="label-row">
<label for="target-editor-brightness-vs" data-i18n="targets.brightness_vs">Brightness Source:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.brightness_vs.hint">Optional value source that dynamically controls brightness each frame (overrides device brightness)</small>
<select id="target-editor-brightness-vs">
<option value="" data-i18n="targets.brightness_vs.none">None (device brightness)</option>
</select>
</div>
<div class="form-group" id="target-editor-fps-group"> <div class="form-group" id="target-editor-fps-group">
<div class="label-row"> <div class="label-row">
<label for="target-editor-fps"> <label for="target-editor-fps">

View File

@@ -0,0 +1,179 @@
<!-- Value Source Editor Modal -->
<div id="value-source-modal" class="modal" role="dialog" aria-labelledby="value-source-modal-title">
<div class="modal-content">
<div class="modal-header">
<h2 id="value-source-modal-title" data-i18n="value_source.add">Add Value Source</h2>
<button class="modal-close-btn" onclick="closeValueSourceModal()" aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<form id="value-source-form" onsubmit="return false;">
<input type="hidden" id="value-source-id">
<div id="value-source-error" class="error-message" style="display: none;"></div>
<!-- Name -->
<div class="form-group">
<div class="label-row">
<label for="value-source-name" data-i18n="value_source.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.name.hint">A descriptive name for this value source</small>
<input type="text" id="value-source-name" data-i18n-placeholder="value_source.name.placeholder" placeholder="Brightness Pulse" required>
</div>
<!-- Type -->
<div class="form-group">
<div class="label-row">
<label for="value-source-type" data-i18n="value_source.type">Type:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.type.hint">Static outputs a constant value. Animated cycles through a waveform. Audio reacts to sound input.</small>
<select id="value-source-type" onchange="onValueSourceTypeChange()">
<option value="static" data-i18n="value_source.type.static">Static</option>
<option value="animated" data-i18n="value_source.type.animated">Animated</option>
<option value="audio" data-i18n="value_source.type.audio">Audio</option>
</select>
</div>
<!-- Static fields -->
<div id="value-source-static-section">
<div class="form-group">
<div class="label-row">
<label for="value-source-value" data-i18n="value_source.value">Value:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.value.hint">Constant output value (0.0 = off, 1.0 = full brightness)</small>
<div class="range-with-value">
<input type="range" id="value-source-value" min="0" max="1" step="0.01" value="1.0"
oninput="document.getElementById('value-source-value-display').textContent = this.value">
<span id="value-source-value-display">1.0</span>
</div>
</div>
</div>
<!-- Animated fields -->
<div id="value-source-animated-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label for="value-source-waveform" data-i18n="value_source.waveform">Waveform:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.waveform.hint">Shape of the brightness animation cycle</small>
<select id="value-source-waveform">
<option value="sine" data-i18n="value_source.waveform.sine">Sine</option>
<option value="triangle" data-i18n="value_source.waveform.triangle">Triangle</option>
<option value="square" data-i18n="value_source.waveform.square">Square</option>
<option value="sawtooth" data-i18n="value_source.waveform.sawtooth">Sawtooth</option>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="value-source-speed" data-i18n="value_source.speed">Speed (cpm):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.speed.hint">Cycles per minute — how fast the waveform repeats (1 = very slow, 120 = very fast)</small>
<div class="range-with-value">
<input type="range" id="value-source-speed" min="1" max="120" step="1" value="10"
oninput="document.getElementById('value-source-speed-display').textContent = this.value">
<span id="value-source-speed-display">10</span>
</div>
</div>
<div class="form-group">
<div class="label-row">
<label for="value-source-min-value" data-i18n="value_source.min_value">Min Value:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.min_value.hint">Minimum output of the waveform cycle</small>
<div class="range-with-value">
<input type="range" id="value-source-min-value" min="0" max="1" step="0.01" value="0"
oninput="document.getElementById('value-source-min-value-display').textContent = this.value">
<span id="value-source-min-value-display">0</span>
</div>
</div>
<div class="form-group">
<div class="label-row">
<label for="value-source-max-value" data-i18n="value_source.max_value">Max Value:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.max_value.hint">Maximum output of the waveform cycle</small>
<div class="range-with-value">
<input type="range" id="value-source-max-value" min="0" max="1" step="0.01" value="1"
oninput="document.getElementById('value-source-max-value-display').textContent = this.value">
<span id="value-source-max-value-display">1</span>
</div>
</div>
</div>
<!-- Audio fields -->
<div id="value-source-audio-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label for="value-source-audio-source" data-i18n="value_source.audio_source">Audio Source:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.audio_source.hint">Mono audio source to read audio levels from</small>
<select id="value-source-audio-source">
<!-- populated dynamically with mono audio sources -->
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="value-source-mode" data-i18n="value_source.mode">Mode:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.mode.hint">RMS measures average volume. Peak tracks loudest moments. Beat triggers on rhythm.</small>
<select id="value-source-mode">
<option value="rms" data-i18n="value_source.mode.rms">RMS (Volume)</option>
<option value="peak" data-i18n="value_source.mode.peak">Peak</option>
<option value="beat" data-i18n="value_source.mode.beat">Beat</option>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="value-source-sensitivity" data-i18n="value_source.sensitivity">Sensitivity:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.sensitivity.hint">Gain multiplier for the audio signal (higher = more reactive)</small>
<div class="range-with-value">
<input type="range" id="value-source-sensitivity" min="0.1" max="5" step="0.1" value="1.0"
oninput="document.getElementById('value-source-sensitivity-display').textContent = this.value">
<span id="value-source-sensitivity-display">1.0</span>
</div>
</div>
<div class="form-group">
<div class="label-row">
<label for="value-source-smoothing" data-i18n="value_source.smoothing">Smoothing:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.smoothing.hint">Temporal smoothing (0 = instant response, 1 = very smooth/slow)</small>
<div class="range-with-value">
<input type="range" id="value-source-smoothing" min="0" max="1" step="0.05" value="0.3"
oninput="document.getElementById('value-source-smoothing-display').textContent = this.value">
<span id="value-source-smoothing-display">0.3</span>
</div>
</div>
</div>
<!-- Description -->
<div class="form-group">
<div class="label-row">
<label for="value-source-description" data-i18n="value_source.description">Description (optional):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.description.hint">Optional notes about this value source</small>
<input type="text" id="value-source-description" data-i18n-placeholder="value_source.description.placeholder" placeholder="Describe this value source...">
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeValueSourceModal()" data-i18n="settings.button.cancel">&times; Cancel</button>
<button class="btn btn-primary" onclick="saveValueSource()" data-i18n="settings.button.save">&check; Save</button>
</div>
</div>
</div>