diff --git a/server/src/wled_controller/api/__init__.py b/server/src/wled_controller/api/__init__.py index 52ad30e..168725c 100644 --- a/server/src/wled_controller/api/__init__.py +++ b/server/src/wled_controller/api/__init__.py @@ -11,6 +11,7 @@ from .routes.pattern_templates import router as pattern_templates_router from .routes.picture_targets import router as picture_targets_router from .routes.color_strip_sources import router as color_strip_sources_router from .routes.audio import router as audio_router +from .routes.audio_sources import router as audio_sources_router from .routes.profiles import router as profiles_router router = APIRouter() @@ -22,6 +23,7 @@ router.include_router(pattern_templates_router) router.include_router(picture_sources_router) router.include_router(color_strip_sources_router) router.include_router(audio_router) +router.include_router(audio_sources_router) router.include_router(picture_targets_router) router.include_router(profiles_router) diff --git a/server/src/wled_controller/api/dependencies.py b/server/src/wled_controller/api/dependencies.py index 10d2710..60c97e8 100644 --- a/server/src/wled_controller/api/dependencies.py +++ b/server/src/wled_controller/api/dependencies.py @@ -8,6 +8,7 @@ from wled_controller.storage.pattern_template_store import PatternTemplateStore from wled_controller.storage.picture_source_store import PictureSourceStore from wled_controller.storage.picture_target_store import PictureTargetStore from wled_controller.storage.color_strip_store import ColorStripStore +from wled_controller.storage.audio_source_store import AudioSourceStore from wled_controller.storage.profile_store import ProfileStore from wled_controller.core.profiles.profile_engine import ProfileEngine @@ -19,6 +20,7 @@ _pattern_template_store: PatternTemplateStore | None = None _picture_source_store: PictureSourceStore | None = None _picture_target_store: PictureTargetStore | None = None _color_strip_store: ColorStripStore | None = None +_audio_source_store: AudioSourceStore | None = None _processor_manager: ProcessorManager | None = None _profile_store: ProfileStore | None = None _profile_engine: ProfileEngine | None = None @@ -73,6 +75,13 @@ def get_color_strip_store() -> ColorStripStore: return _color_strip_store +def get_audio_source_store() -> AudioSourceStore: + """Get audio source store dependency.""" + if _audio_source_store is None: + raise RuntimeError("Audio source store not initialized") + return _audio_source_store + + def get_processor_manager() -> ProcessorManager: """Get processor manager dependency.""" if _processor_manager is None: @@ -103,13 +112,14 @@ def init_dependencies( picture_source_store: PictureSourceStore | None = None, picture_target_store: PictureTargetStore | None = None, color_strip_store: ColorStripStore | None = None, + audio_source_store: AudioSourceStore | None = None, profile_store: ProfileStore | None = None, profile_engine: ProfileEngine | None = None, ): """Initialize global dependencies.""" global _device_store, _template_store, _processor_manager global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_store - global _color_strip_store, _profile_store, _profile_engine + global _color_strip_store, _audio_source_store, _profile_store, _profile_engine _device_store = device_store _template_store = template_store _processor_manager = processor_manager @@ -118,5 +128,6 @@ def init_dependencies( _picture_source_store = picture_source_store _picture_target_store = picture_target_store _color_strip_store = color_strip_store + _audio_source_store = audio_source_store _profile_store = profile_store _profile_engine = profile_engine diff --git a/server/src/wled_controller/api/routes/audio_sources.py b/server/src/wled_controller/api/routes/audio_sources.py new file mode 100644 index 0000000..3e22b77 --- /dev/null +++ b/server/src/wled_controller/api/routes/audio_sources.py @@ -0,0 +1,139 @@ +"""Audio source routes: CRUD for audio 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_audio_source_store, + get_color_strip_store, +) +from wled_controller.api.schemas.audio_sources import ( + AudioSourceCreate, + AudioSourceListResponse, + AudioSourceResponse, + AudioSourceUpdate, +) +from wled_controller.storage.audio_source import AudioSource +from wled_controller.storage.audio_source_store import AudioSourceStore +from wled_controller.storage.color_strip_store import ColorStripStore +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + +router = APIRouter() + + +def _to_response(source: AudioSource) -> AudioSourceResponse: + """Convert an AudioSource to an AudioSourceResponse.""" + return AudioSourceResponse( + id=source.id, + name=source.name, + source_type=source.source_type, + device_index=getattr(source, "device_index", None), + is_loopback=getattr(source, "is_loopback", None), + audio_source_id=getattr(source, "audio_source_id", None), + channel=getattr(source, "channel", None), + description=source.description, + created_at=source.created_at, + updated_at=source.updated_at, + ) + + +@router.get("/api/v1/audio-sources", response_model=AudioSourceListResponse, tags=["Audio Sources"]) +async def list_audio_sources( + _auth: AuthRequired, + source_type: Optional[str] = Query(None, description="Filter by source_type: multichannel or mono"), + store: AudioSourceStore = Depends(get_audio_source_store), +): + """List all audio 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 AudioSourceListResponse( + sources=[_to_response(s) for s in sources], + count=len(sources), + ) + + +@router.post("/api/v1/audio-sources", response_model=AudioSourceResponse, status_code=201, tags=["Audio Sources"]) +async def create_audio_source( + data: AudioSourceCreate, + _auth: AuthRequired, + store: AudioSourceStore = Depends(get_audio_source_store), +): + """Create a new audio source.""" + try: + source = store.create_source( + name=data.name, + source_type=data.source_type, + device_index=data.device_index, + is_loopback=data.is_loopback, + audio_source_id=data.audio_source_id, + channel=data.channel, + description=data.description, + ) + return _to_response(source) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/api/v1/audio-sources/{source_id}", response_model=AudioSourceResponse, tags=["Audio Sources"]) +async def get_audio_source( + source_id: str, + _auth: AuthRequired, + store: AudioSourceStore = Depends(get_audio_source_store), +): + """Get an audio 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/audio-sources/{source_id}", response_model=AudioSourceResponse, tags=["Audio Sources"]) +async def update_audio_source( + source_id: str, + data: AudioSourceUpdate, + _auth: AuthRequired, + store: AudioSourceStore = Depends(get_audio_source_store), +): + """Update an existing audio source.""" + try: + source = store.update_source( + source_id=source_id, + name=data.name, + device_index=data.device_index, + is_loopback=data.is_loopback, + audio_source_id=data.audio_source_id, + channel=data.channel, + description=data.description, + ) + return _to_response(source) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.delete("/api/v1/audio-sources/{source_id}", tags=["Audio Sources"]) +async def delete_audio_source( + source_id: str, + _auth: AuthRequired, + store: AudioSourceStore = Depends(get_audio_source_store), + css_store: ColorStripStore = Depends(get_color_strip_store), +): + """Delete an audio source.""" + try: + # Check if any CSS entities reference this audio source + from wled_controller.storage.color_strip_source import AudioColorStripSource + for css in css_store.get_all_sources(): + if isinstance(css, AudioColorStripSource) and getattr(css, "audio_source_id", None) == source_id: + raise ValueError( + f"Cannot delete: referenced by color strip source '{css.name}'" + ) + + store.delete_source(source_id) + return {"status": "deleted", "id": source_id} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) diff --git a/server/src/wled_controller/api/routes/color_strip_sources.py b/server/src/wled_controller/api/routes/color_strip_sources.py index 3ae2222..cfafed2 100644 --- a/server/src/wled_controller/api/routes/color_strip_sources.py +++ b/server/src/wled_controller/api/routes/color_strip_sources.py @@ -80,10 +80,9 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe frame_interpolation=getattr(source, "frame_interpolation", None), animation=getattr(source, "animation", None), layers=getattr(source, "layers", None), + zones=getattr(source, "zones", None), visualization_mode=getattr(source, "visualization_mode", None), - audio_device_index=getattr(source, "audio_device_index", None), - audio_loopback=getattr(source, "audio_loopback", None), - audio_channel=getattr(source, "audio_channel", None), + audio_source_id=getattr(source, "audio_source_id", None), sensitivity=getattr(source, "sensitivity", None), color_peak=getattr(source, "color_peak", None), overlay_active=overlay_active, @@ -137,6 +136,8 @@ async def create_color_strip_source( layers = [l.model_dump() for l in data.layers] if data.layers is not None else None + zones = [z.model_dump() for z in data.zones] if data.zones is not None else None + source = store.create_source( name=data.name, source_type=data.source_type, @@ -162,10 +163,9 @@ async def create_color_strip_source( scale=data.scale, mirror=data.mirror, layers=layers, + zones=zones, visualization_mode=data.visualization_mode, - audio_device_index=data.audio_device_index, - audio_loopback=data.audio_loopback, - audio_channel=data.audio_channel, + audio_source_id=data.audio_source_id, sensitivity=data.sensitivity, color_peak=data.color_peak, ) @@ -211,6 +211,8 @@ async def update_color_strip_source( layers = [l.model_dump() for l in data.layers] if data.layers is not None else None + zones = [z.model_dump() for z in data.zones] if data.zones is not None else None + source = store.update_source( source_id=source_id, name=data.name, @@ -236,10 +238,9 @@ async def update_color_strip_source( scale=data.scale, mirror=data.mirror, layers=layers, + zones=zones, visualization_mode=data.visualization_mode, - audio_device_index=data.audio_device_index, - audio_loopback=data.audio_loopback, - audio_channel=data.audio_channel, + audio_source_id=data.audio_source_id, sensitivity=data.sensitivity, color_peak=data.color_peak, ) @@ -284,6 +285,14 @@ async def delete_color_strip_source( detail=f"Color strip source is used as a layer in composite source(s): {names}. " "Remove it from the composite(s) first.", ) + mapped_names = store.get_mapped_referencing(source_id) + if mapped_names: + names = ", ".join(mapped_names) + raise HTTPException( + status_code=409, + detail=f"Color strip source is used as a zone in mapped source(s): {names}. " + "Remove it from the mapped source(s) first.", + ) store.delete_source(source_id) except HTTPException: raise diff --git a/server/src/wled_controller/api/schemas/audio_sources.py b/server/src/wled_controller/api/schemas/audio_sources.py new file mode 100644 index 0000000..b31a119 --- /dev/null +++ b/server/src/wled_controller/api/schemas/audio_sources.py @@ -0,0 +1,53 @@ +"""Audio source schemas (CRUD).""" + +from datetime import datetime +from typing import List, Literal, Optional + +from pydantic import BaseModel, Field + + +class AudioSourceCreate(BaseModel): + """Request to create an audio source.""" + + name: str = Field(description="Source name", min_length=1, max_length=100) + source_type: Literal["multichannel", "mono"] = Field(description="Source type") + # multichannel fields + device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)") + is_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback)") + # mono fields + audio_source_id: Optional[str] = Field(None, description="Parent multichannel audio source ID") + channel: Optional[str] = Field(None, description="Channel: mono|left|right") + description: Optional[str] = Field(None, description="Optional description", max_length=500) + + +class AudioSourceUpdate(BaseModel): + """Request to update an audio source.""" + + name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100) + device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)") + is_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback)") + audio_source_id: Optional[str] = Field(None, description="Parent multichannel audio source ID") + channel: Optional[str] = Field(None, description="Channel: mono|left|right") + description: Optional[str] = Field(None, description="Optional description", max_length=500) + + +class AudioSourceResponse(BaseModel): + """Audio source response.""" + + id: str = Field(description="Source ID") + name: str = Field(description="Source name") + source_type: str = Field(description="Source type: multichannel or mono") + device_index: Optional[int] = Field(None, description="Audio device index") + is_loopback: Optional[bool] = Field(None, description="WASAPI loopback mode") + audio_source_id: Optional[str] = Field(None, description="Parent multichannel source ID") + channel: Optional[str] = Field(None, description="Channel: mono|left|right") + description: Optional[str] = Field(None, description="Description") + created_at: datetime = Field(description="Creation timestamp") + updated_at: datetime = Field(description="Last update timestamp") + + +class AudioSourceListResponse(BaseModel): + """List of audio sources.""" + + sources: List[AudioSourceResponse] = Field(description="List of audio sources") + count: int = Field(description="Number of sources") diff --git a/server/src/wled_controller/api/schemas/color_strip_sources.py b/server/src/wled_controller/api/schemas/color_strip_sources.py index ded7595..154fda8 100644 --- a/server/src/wled_controller/api/schemas/color_strip_sources.py +++ b/server/src/wled_controller/api/schemas/color_strip_sources.py @@ -36,11 +36,20 @@ class CompositeLayer(BaseModel): enabled: bool = Field(default=True, description="Whether this layer is active") +class MappedZone(BaseModel): + """A single zone in a mapped color strip source.""" + + source_id: str = Field(description="ID of the zone's color strip source") + start: int = Field(default=0, ge=0, description="First LED index (0-based)") + end: int = Field(default=0, ge=0, description="Last LED index (exclusive); 0 = auto-fill") + reverse: bool = Field(default=False, description="Reverse zone output") + + class ColorStripSourceCreate(BaseModel): """Request to create a color strip source.""" name: str = Field(description="Source name", min_length=1, max_length=100) - source_type: Literal["picture", "static", "gradient", "color_cycle", "effect", "composite", "audio"] = Field(default="picture", description="Source type") + source_type: Literal["picture", "static", "gradient", "color_cycle", "effect", "composite", "mapped", "audio"] = Field(default="picture", description="Source type") # picture-type fields picture_source_id: str = Field(default="", description="Picture source ID (for picture type)") brightness: float = Field(default=1.0, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0) @@ -65,13 +74,13 @@ class ColorStripSourceCreate(BaseModel): mirror: Optional[bool] = Field(None, description="Mirror/bounce mode (meteor)") # composite-type fields layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type") + # mapped-type fields + zones: Optional[List[MappedZone]] = Field(None, description="Zones for mapped type") # audio-type fields visualization_mode: Optional[str] = Field(None, description="Audio visualization: spectrum|beat_pulse|vu_meter") - audio_device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)") - audio_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback), False for mic/line-in") + audio_source_id: Optional[str] = Field(None, description="Mono audio source ID (for audio type)") sensitivity: Optional[float] = Field(None, description="Audio sensitivity/gain 0.1-5.0", ge=0.1, le=5.0) color_peak: Optional[List[int]] = Field(None, description="Peak/high RGB color for VU meter [R,G,B]") - audio_channel: Optional[str] = Field(None, description="Audio channel: mono|left|right") # shared led_count: int = Field(default=0, description="Total LED count (0 = auto from calibration / device)", ge=0) description: Optional[str] = Field(None, description="Optional description", max_length=500) @@ -107,13 +116,13 @@ class ColorStripSourceUpdate(BaseModel): mirror: Optional[bool] = Field(None, description="Mirror/bounce mode") # composite-type fields layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type") + # mapped-type fields + zones: Optional[List[MappedZone]] = Field(None, description="Zones for mapped type") # audio-type fields visualization_mode: Optional[str] = Field(None, description="Audio visualization: spectrum|beat_pulse|vu_meter") - audio_device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)") - audio_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback), False for mic/line-in") + audio_source_id: Optional[str] = Field(None, description="Mono audio source ID (for audio type)") sensitivity: Optional[float] = Field(None, description="Audio sensitivity/gain 0.1-5.0", ge=0.1, le=5.0) color_peak: Optional[List[int]] = Field(None, description="Peak/high RGB color for VU meter [R,G,B]") - audio_channel: Optional[str] = Field(None, description="Audio channel: mono|left|right") # shared led_count: Optional[int] = Field(None, description="Total LED count (0 = auto from calibration / device)", ge=0) description: Optional[str] = Field(None, description="Optional description", max_length=500) @@ -151,13 +160,13 @@ class ColorStripSourceResponse(BaseModel): mirror: Optional[bool] = Field(None, description="Mirror/bounce mode") # composite-type fields layers: Optional[List[dict]] = Field(None, description="Layers for composite type") + # mapped-type fields + zones: Optional[List[dict]] = Field(None, description="Zones for mapped type") # audio-type fields visualization_mode: Optional[str] = Field(None, description="Audio visualization mode") - audio_device_index: Optional[int] = Field(None, description="Audio device index") - audio_loopback: Optional[bool] = Field(None, description="WASAPI loopback mode") + audio_source_id: Optional[str] = Field(None, description="Mono audio source ID") sensitivity: Optional[float] = Field(None, description="Audio sensitivity") color_peak: Optional[List[int]] = Field(None, description="Peak color [R,G,B]") - audio_channel: Optional[str] = Field(None, description="Audio channel: mono|left|right") # shared led_count: int = Field(0, description="Total LED count (0 = auto from calibration / device)") description: Optional[str] = Field(None, description="Description") diff --git a/server/src/wled_controller/config.py b/server/src/wled_controller/config.py index 2cbf627..dbea9d3 100644 --- a/server/src/wled_controller/config.py +++ b/server/src/wled_controller/config.py @@ -34,6 +34,7 @@ class StorageConfig(BaseSettings): picture_targets_file: str = "data/picture_targets.json" pattern_templates_file: str = "data/pattern_templates.json" color_strip_sources_file: str = "data/color_strip_sources.json" + audio_sources_file: str = "data/audio_sources.json" profiles_file: str = "data/profiles.json" diff --git a/server/src/wled_controller/core/processing/audio_stream.py b/server/src/wled_controller/core/processing/audio_stream.py index 9dfa0bb..fadc5b5 100644 --- a/server/src/wled_controller/core/processing/audio_stream.py +++ b/server/src/wled_controller/core/processing/audio_stream.py @@ -35,8 +35,9 @@ class AudioColorStripStream(ColorStripStream): thread, double-buffered output, configure() for auto-sizing. """ - def __init__(self, source, audio_capture_manager: AudioCaptureManager): + def __init__(self, source, audio_capture_manager: AudioCaptureManager, audio_source_store=None): self._audio_capture_manager = audio_capture_manager + self._audio_source_store = audio_source_store self._audio_stream = None # acquired on start self._colors_lock = threading.Lock() @@ -55,8 +56,6 @@ class AudioColorStripStream(ColorStripStream): def _update_from_source(self, source) -> None: self._visualization_mode = getattr(source, "visualization_mode", "spectrum") - self._audio_device_index = getattr(source, "audio_device_index", -1) - self._audio_loopback = bool(getattr(source, "audio_loopback", True)) self._sensitivity = float(getattr(source, "sensitivity", 1.0)) self._smoothing = float(getattr(source, "smoothing", 0.3)) self._palette_name = getattr(source, "palette", "rainbow") @@ -68,7 +67,26 @@ class AudioColorStripStream(ColorStripStream): self._auto_size = not source.led_count self._led_count = source.led_count if source.led_count and source.led_count > 0 else 1 self._mirror = bool(getattr(source, "mirror", False)) - self._audio_channel = getattr(source, "audio_channel", "mono") # mono | left | right + + # Resolve audio device/channel via audio_source_id + audio_source_id = getattr(source, "audio_source_id", "") + self._audio_source_id = audio_source_id + if audio_source_id and self._audio_source_store: + try: + device_index, is_loopback, channel = self._audio_source_store.resolve_mono_source(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 {audio_source_id}: {e}") + self._audio_device_index = -1 + self._audio_loopback = True + self._audio_channel = "mono" + else: + self._audio_device_index = -1 + self._audio_loopback = True + self._audio_channel = "mono" + with self._colors_lock: self._colors: Optional[np.ndarray] = None diff --git a/server/src/wled_controller/core/processing/color_strip_stream_manager.py b/server/src/wled_controller/core/processing/color_strip_stream_manager.py index 1719544..a83fe3b 100644 --- a/server/src/wled_controller/core/processing/color_strip_stream_manager.py +++ b/server/src/wled_controller/core/processing/color_strip_stream_manager.py @@ -56,16 +56,18 @@ class ColorStripStreamManager: keyed by ``{css_id}:{consumer_id}``. """ - def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None): + def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None, audio_source_store=None): """ Args: color_strip_store: ColorStripStore for resolving source configs live_stream_manager: LiveStreamManager for acquiring picture streams audio_capture_manager: AudioCaptureManager for audio-reactive sources + audio_source_store: AudioSourceStore for resolving audio source chains """ self._color_strip_store = color_strip_store self._live_stream_manager = live_stream_manager self._audio_capture_manager = audio_capture_manager + self._audio_source_store = audio_source_store self._streams: Dict[str, _ColorStripEntry] = {} def _resolve_key(self, css_id: str, consumer_id: str) -> str: @@ -104,10 +106,13 @@ class ColorStripStreamManager: if not source.sharable: if source.source_type == "audio": from wled_controller.core.processing.audio_stream import AudioColorStripStream - css_stream = AudioColorStripStream(source, self._audio_capture_manager) + css_stream = AudioColorStripStream(source, self._audio_capture_manager, self._audio_source_store) elif source.source_type == "composite": from wled_controller.core.processing.composite_stream import CompositeColorStripStream css_stream = CompositeColorStripStream(source, self) + elif source.source_type == "mapped": + from wled_controller.core.processing.mapped_stream import MappedColorStripStream + css_stream = MappedColorStripStream(source, self) else: stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type) if not stream_cls: diff --git a/server/src/wled_controller/core/processing/mapped_stream.py b/server/src/wled_controller/core/processing/mapped_stream.py new file mode 100644 index 0000000..13d73da --- /dev/null +++ b/server/src/wled_controller/core/processing/mapped_stream.py @@ -0,0 +1,212 @@ +"""Mapped color strip stream — places different sources at different LED ranges.""" + +import threading +import time +from typing import Dict, List, Optional + +import numpy as np + +from wled_controller.core.processing.color_strip_stream import ColorStripStream +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + + +class MappedColorStripStream(ColorStripStream): + """Places multiple ColorStripStreams side-by-side at distinct LED ranges. + + Each zone references an existing (non-mapped) ColorStripSource and is + assigned a start/end LED range. Unlike composite (which blends layers + covering ALL LEDs), mapped assigns each source to a distinct sub-range. + Gaps between zones stay black (zeros). + + Processing runs in a background thread at 30 FPS, polling each + sub-stream's latest colors and copying into the output array. + """ + + def __init__(self, source, css_manager): + self._source_id: str = source.id + self._zones: List[dict] = list(source.zones) + self._led_count: int = source.led_count + self._auto_size: bool = source.led_count == 0 + self._css_manager = css_manager + self._fps: int = 30 + + self._running = False + self._thread: Optional[threading.Thread] = None + self._latest_colors: Optional[np.ndarray] = None + self._colors_lock = threading.Lock() + + # zone_index -> (source_id, consumer_id, stream) + self._sub_streams: Dict[int, tuple] = {} + + # ── ColorStripStream interface ────────────────────────────── + + @property + def target_fps(self) -> int: + return self._fps + + @property + def led_count(self) -> int: + return self._led_count + + @property + def is_animated(self) -> bool: + return True + + def start(self) -> None: + self._acquire_sub_streams() + self._running = True + self._thread = threading.Thread( + target=self._processing_loop, daemon=True, + name=f"MappedCSS-{self._source_id[:12]}", + ) + self._thread.start() + logger.info( + f"MappedColorStripStream started: {self._source_id} " + f"({len(self._sub_streams)} zones, {self._led_count} LEDs)" + ) + + def stop(self) -> None: + self._running = False + if self._thread is not None: + self._thread.join(timeout=5.0) + self._thread = None + self._release_sub_streams() + logger.info(f"MappedColorStripStream stopped: {self._source_id}") + + def get_latest_colors(self) -> Optional[np.ndarray]: + with self._colors_lock: + return self._latest_colors + + def configure(self, device_led_count: int) -> None: + if self._auto_size and device_led_count > 0 and device_led_count != self._led_count: + self._led_count = device_led_count + self._reconfigure_sub_streams() + logger.debug(f"MappedColorStripStream auto-sized to {device_led_count} LEDs") + + def update_source(self, source) -> None: + """Hot-update: rebuild sub-streams if zone config changed.""" + new_zones = list(source.zones) + old_zone_ids = [(z.get("source_id"), z.get("start"), z.get("end"), z.get("reverse")) + for z in self._zones] + new_zone_ids = [(z.get("source_id"), z.get("start"), z.get("end"), z.get("reverse")) + for z in new_zones] + + self._zones = new_zones + + if source.led_count != 0: + self._led_count = source.led_count + self._auto_size = False + + if old_zone_ids != new_zone_ids: + self._release_sub_streams() + self._acquire_sub_streams() + logger.info(f"MappedColorStripStream rebuilt sub-streams: {self._source_id}") + + # ── Sub-stream lifecycle ──────────────────────────────────── + + def _acquire_sub_streams(self) -> None: + for i, zone in enumerate(self._zones): + src_id = zone.get("source_id", "") + if not src_id: + continue + consumer_id = f"{self._source_id}__zone_{i}" + try: + stream = self._css_manager.acquire(src_id, consumer_id) + zone_len = self._zone_length(zone) + if hasattr(stream, "configure") and zone_len > 0: + stream.configure(zone_len) + self._sub_streams[i] = (src_id, consumer_id, stream) + except Exception as e: + logger.warning( + f"Mapped zone {i} (source {src_id}) failed to acquire: {e}" + ) + + def _release_sub_streams(self) -> None: + for _idx, (src_id, consumer_id, _stream) in list(self._sub_streams.items()): + try: + self._css_manager.release(src_id, consumer_id) + except Exception as e: + logger.warning(f"Mapped zone release error ({src_id}): {e}") + self._sub_streams.clear() + + def _reconfigure_sub_streams(self) -> None: + """Reconfigure zone sub-streams with updated LED ranges.""" + for i, zone in enumerate(self._zones): + if i not in self._sub_streams: + continue + _src_id, _consumer_id, stream = self._sub_streams[i] + zone_len = self._zone_length(zone) + if hasattr(stream, "configure") and zone_len > 0: + stream.configure(zone_len) + + def _zone_length(self, zone: dict) -> int: + """Calculate LED count for a zone. end=0 means auto-fill to total.""" + start = zone.get("start", 0) + end = zone.get("end", 0) + if end <= 0: + end = self._led_count + return max(0, end - start) + + # ── Processing loop ───────────────────────────────────────── + + def _processing_loop(self) -> None: + while self._running: + loop_start = time.perf_counter() + frame_time = 1.0 / self._fps + + try: + target_n = self._led_count + if target_n <= 0: + time.sleep(frame_time) + continue + + result = np.zeros((target_n, 3), dtype=np.uint8) + + for i, zone in enumerate(self._zones): + if i not in self._sub_streams: + continue + + _src_id, _consumer_id, stream = self._sub_streams[i] + colors = stream.get_latest_colors() + if colors is None: + continue + + start = zone.get("start", 0) + end = zone.get("end", 0) + if end <= 0: + end = target_n + start = max(0, min(start, target_n)) + end = max(start, min(end, target_n)) + zone_len = end - start + + if zone_len <= 0: + continue + + # Resize sub-stream output to zone length if needed + if len(colors) != zone_len: + src_x = np.linspace(0, 1, len(colors)) + dst_x = np.linspace(0, 1, zone_len) + resized = np.empty((zone_len, 3), dtype=np.uint8) + for ch in range(3): + np.copyto( + resized[:, ch], + np.interp(dst_x, src_x, colors[:, ch]), + casting="unsafe", + ) + colors = resized + + if zone.get("reverse", False): + colors = colors[::-1] + + result[start:end] = colors + + with self._colors_lock: + self._latest_colors = result + + except Exception as e: + logger.error(f"MappedColorStripStream processing error: {e}", exc_info=True) + + elapsed = time.perf_counter() - loop_start + time.sleep(max(frame_time - elapsed, 0.001)) diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index 0ea87a2..2709c8e 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -64,7 +64,7 @@ class ProcessorManager: Targets are registered for processing via polymorphic TargetProcessor subclasses. """ - def __init__(self, picture_source_store=None, capture_template_store=None, pp_template_store=None, pattern_template_store=None, device_store=None, color_strip_store=None): + 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): """Initialize processor manager.""" self._devices: Dict[str, DeviceState] = {} self._processors: Dict[str, TargetProcessor] = {} @@ -77,6 +77,7 @@ class ProcessorManager: self._pattern_template_store = pattern_template_store self._device_store = device_store self._color_strip_store = color_strip_store + self._audio_source_store = audio_source_store self._live_stream_manager = LiveStreamManager( picture_source_store, capture_template_store, pp_template_store ) @@ -85,6 +86,7 @@ class ProcessorManager: color_strip_store=color_strip_store, live_stream_manager=self._live_stream_manager, audio_capture_manager=self._audio_capture_manager, + audio_source_store=audio_source_store, ) self._overlay_manager = OverlayManager() self._event_queues: List[asyncio.Queue] = [] diff --git a/server/src/wled_controller/main.py b/server/src/wled_controller/main.py index 06c9ae8..448bfa7 100644 --- a/server/src/wled_controller/main.py +++ b/server/src/wled_controller/main.py @@ -23,6 +23,7 @@ from wled_controller.storage.pattern_template_store import PatternTemplateStore from wled_controller.storage.picture_source_store import PictureSourceStore from wled_controller.storage.picture_target_store import PictureTargetStore from wled_controller.storage.color_strip_store import ColorStripStore +from wled_controller.storage.audio_source_store import AudioSourceStore from wled_controller.storage.profile_store import ProfileStore from wled_controller.core.profiles.profile_engine import ProfileEngine from wled_controller.utils import setup_logging, get_logger @@ -42,8 +43,12 @@ picture_source_store = PictureSourceStore(config.storage.picture_sources_file) picture_target_store = PictureTargetStore(config.storage.picture_targets_file) pattern_template_store = PatternTemplateStore(config.storage.pattern_templates_file) color_strip_store = ColorStripStore(config.storage.color_strip_sources_file) +audio_source_store = AudioSourceStore(config.storage.audio_sources_file) profile_store = ProfileStore(config.storage.profiles_file) +# Migrate embedded audio config from CSS entities to audio sources +audio_source_store.migrate_from_css(color_strip_store) + processor_manager = ProcessorManager( picture_source_store=picture_source_store, capture_template_store=template_store, @@ -51,6 +56,7 @@ processor_manager = ProcessorManager( pattern_template_store=pattern_template_store, device_store=device_store, color_strip_store=color_strip_store, + audio_source_store=audio_source_store, ) @@ -94,6 +100,7 @@ async def lifespan(app: FastAPI): picture_source_store=picture_source_store, picture_target_store=picture_target_store, color_strip_store=color_strip_store, + audio_source_store=audio_source_store, profile_store=profile_store, profile_engine=profile_engine, ) diff --git a/server/src/wled_controller/static/css/layout.css b/server/src/wled_controller/static/css/layout.css index 61d2020..e78c735 100644 --- a/server/src/wled_controller/static/css/layout.css +++ b/server/src/wled_controller/static/css/layout.css @@ -2,8 +2,8 @@ header { display: flex; justify-content: space-between; align-items: center; - padding: 20px 0 10px; - margin-bottom: 10px; + padding: 8px 0 6px; + margin-bottom: 6px; position: relative; z-index: 100; } @@ -11,11 +11,11 @@ header { .header-title { display: flex; align-items: center; - gap: 10px; + gap: 8px; } h1 { - font-size: 2rem; + font-size: 1.25rem; color: var(--primary-text-color); } @@ -28,7 +28,7 @@ h2 { .server-info { display: flex; align-items: center; - gap: 15px; + gap: 8px; } .header-link { @@ -57,7 +57,7 @@ h2 { } .status-badge { - font-size: 1.5rem; + font-size: 1rem; animation: pulse 2s infinite; } @@ -156,12 +156,12 @@ h2 { .theme-toggle { background: var(--card-bg); border: 1px solid var(--border-color); - padding: 6px 12px; + padding: 4px 8px; border-radius: 4px; cursor: pointer; - font-size: 1.2rem; + font-size: 1rem; transition: transform 0.2s; - margin-left: 10px; + margin-left: 0; } .theme-toggle:hover { @@ -170,14 +170,14 @@ h2 { /* Footer */ .app-footer { - margin-top: 20px; - padding: 15px 0; + margin-top: 12px; + padding: 6px 0; text-align: center; } .footer-content { color: var(--text-secondary); - font-size: 0.9rem; + font-size: 0.75rem; } .footer-content p { @@ -203,7 +203,7 @@ h2 { @media (max-width: 768px) { header { flex-direction: column; - gap: 15px; + gap: 8px; text-align: center; } } diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index e385e43..8007ba0 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -85,7 +85,7 @@ import { import { loadTargetsTab, loadTargets, switchTargetSubTab, showTargetEditor, closeTargetEditorModal, forceCloseTargetEditorModal, saveTargetEditor, - addTargetSegment, removeTargetSegment, + addTargetSegment, removeTargetSegment, syncSegmentsMappedMode, startTargetProcessing, stopTargetProcessing, startTargetOverlay, stopTargetOverlay, deleteTarget, cloneTarget, @@ -97,11 +97,18 @@ import { onCSSTypeChange, onEffectTypeChange, onAnimationTypeChange, updateEffectPreview, colorCycleAddColor, colorCycleRemoveColor, compositeAddLayer, compositeRemoveLayer, + mappedAddZone, mappedRemoveZone, onAudioVizChange, applyGradientPreset, cloneColorStrip, } from './features/color-strips.js'; +// Layer 5: audio sources +import { + showAudioSourceModal, closeAudioSourceModal, saveAudioSource, + editAudioSource, deleteAudioSource, onAudioSourceTypeChange, +} from './features/audio-sources.js'; + // Layer 5: calibration import { showCalibration, closeCalibrationModal, forceCloseCalibrationModal, saveCalibration, @@ -278,6 +285,7 @@ Object.assign(window, { saveTargetEditor, addTargetSegment, removeTargetSegment, + syncSegmentsMappedMode, startTargetProcessing, stopTargetProcessing, startTargetOverlay, @@ -299,10 +307,20 @@ Object.assign(window, { colorCycleRemoveColor, compositeAddLayer, compositeRemoveLayer, + mappedAddZone, + mappedRemoveZone, onAudioVizChange, applyGradientPreset, cloneColorStrip, + // audio sources + showAudioSourceModal, + closeAudioSourceModal, + saveAudioSource, + editAudioSource, + deleteAudioSource, + onAudioSourceTypeChange, + // calibration showCalibration, closeCalibrationModal, diff --git a/server/src/wled_controller/static/js/core/state.js b/server/src/wled_controller/static/js/core/state.js index 96b11ce..f9e0b60 100644 --- a/server/src/wled_controller/static/js/core/state.js +++ b/server/src/wled_controller/static/js/core/state.js @@ -165,6 +165,10 @@ export const PATTERN_RECT_BORDERS = [ '#4CAF50', '#2196F3', '#FF9800', '#9C27B0', '#00BCD4', '#F44336', '#FFEB3B', '#795548', ]; +// Audio sources +export let _cachedAudioSources = []; +export function set_cachedAudioSources(v) { _cachedAudioSources = v; } + // Profiles export let _profilesCache = null; export function set_profilesCache(v) { _profilesCache = v; } diff --git a/server/src/wled_controller/static/js/features/audio-sources.js b/server/src/wled_controller/static/js/features/audio-sources.js new file mode 100644 index 0000000..03953a7 --- /dev/null +++ b/server/src/wled_controller/static/js/features/audio-sources.js @@ -0,0 +1,193 @@ +/** + * Audio Sources — CRUD for multichannel and mono audio sources. + * + * Audio sources are managed entities that encapsulate audio device + * configuration. Multichannel sources represent physical audio devices; + * mono sources extract a single channel from a multichannel source. + * CSS audio type references a mono source by ID. + * + * Card rendering is handled by streams.js (Audio tab). + * This module manages the editor modal and API operations. + */ + +import { _cachedAudioSources, set_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 audioSourceModal = new Modal('audio-source-modal'); + +// ── Modal ───────────────────────────────────────────────────── + +export async function showAudioSourceModal(sourceType, editData) { + const isEdit = !!editData; + const titleKey = isEdit + ? (editData.source_type === 'mono' ? 'audio_source.edit.mono' : 'audio_source.edit.multichannel') + : (sourceType === 'mono' ? 'audio_source.add.mono' : 'audio_source.add.multichannel'); + + document.getElementById('audio-source-modal-title').textContent = t(titleKey); + document.getElementById('audio-source-id').value = isEdit ? editData.id : ''; + document.getElementById('audio-source-error').style.display = 'none'; + + const typeSelect = document.getElementById('audio-source-type'); + typeSelect.value = isEdit ? editData.source_type : sourceType; + typeSelect.disabled = isEdit; // can't change type after creation + + onAudioSourceTypeChange(); + + if (isEdit) { + document.getElementById('audio-source-name').value = editData.name || ''; + document.getElementById('audio-source-description').value = editData.description || ''; + + if (editData.source_type === 'multichannel') { + await _loadAudioDevices(); + _selectAudioDevice(editData.device_index, editData.is_loopback); + } else { + _loadMultichannelSources(editData.audio_source_id); + document.getElementById('audio-source-channel').value = editData.channel || 'mono'; + } + } else { + document.getElementById('audio-source-name').value = ''; + document.getElementById('audio-source-description').value = ''; + + if (sourceType === 'multichannel') { + await _loadAudioDevices(); + } else { + _loadMultichannelSources(); + } + } + + audioSourceModal.open(); +} + +export function closeAudioSourceModal() { + audioSourceModal.forceClose(); +} + +export function onAudioSourceTypeChange() { + const type = document.getElementById('audio-source-type').value; + document.getElementById('audio-source-multichannel-section').style.display = type === 'multichannel' ? '' : 'none'; + document.getElementById('audio-source-mono-section').style.display = type === 'mono' ? '' : 'none'; +} + +// ── Save ────────────────────────────────────────────────────── + +export async function saveAudioSource() { + const id = document.getElementById('audio-source-id').value; + const name = document.getElementById('audio-source-name').value.trim(); + const sourceType = document.getElementById('audio-source-type').value; + const description = document.getElementById('audio-source-description').value.trim() || null; + const errorEl = document.getElementById('audio-source-error'); + + if (!name) { + errorEl.textContent = t('audio_source.error.name_required'); + errorEl.style.display = ''; + return; + } + + const payload = { name, source_type: sourceType, description }; + + if (sourceType === 'multichannel') { + const deviceVal = document.getElementById('audio-source-device').value || '-1:1'; + const [devIdx, devLoop] = deviceVal.split(':'); + payload.device_index = parseInt(devIdx) || -1; + payload.is_loopback = devLoop !== '0'; + } else { + payload.audio_source_id = document.getElementById('audio-source-parent').value; + payload.channel = document.getElementById('audio-source-channel').value; + } + + try { + const method = id ? 'PUT' : 'POST'; + const url = id ? `/audio-sources/${id}` : '/audio-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 ? 'audio_source.updated' : 'audio_source.created'), 'success'); + audioSourceModal.forceClose(); + await loadPictureSources(); + } catch (e) { + errorEl.textContent = e.message; + errorEl.style.display = ''; + } +} + +// ── Edit ────────────────────────────────────────────────────── + +export async function editAudioSource(sourceId) { + try { + const resp = await fetchWithAuth(`/audio-sources/${sourceId}`); + if (!resp.ok) throw new Error('fetch failed'); + const data = await resp.json(); + await showAudioSourceModal(data.source_type, data); + } catch (e) { + showToast(e.message, 'error'); + } +} + +// ── Delete ──────────────────────────────────────────────────── + +export async function deleteAudioSource(sourceId) { + const confirmed = await showConfirm(t('audio_source.delete.confirm')); + if (!confirmed) return; + + try { + const resp = await fetchWithAuth(`/audio-sources/${sourceId}`, { method: 'DELETE' }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.detail || `HTTP ${resp.status}`); + } + showToast(t('audio_source.deleted'), 'success'); + await loadPictureSources(); + } catch (e) { + showToast(e.message, 'error'); + } +} + +// ── Helpers ─────────────────────────────────────────────────── + +async function _loadAudioDevices() { + const select = document.getElementById('audio-source-device'); + if (!select) return; + try { + const resp = await fetchWithAuth('/audio-devices'); + if (!resp.ok) throw new Error('fetch failed'); + const data = await resp.json(); + const devices = data.devices || []; + select.innerHTML = devices.map(d => { + const label = d.is_loopback ? `🔊 ${d.name}` : `🎤 ${d.name}`; + const val = `${d.index}:${d.is_loopback ? '1' : '0'}`; + return ``; + }).join(''); + if (devices.length === 0) { + select.innerHTML = ''; + } + } catch { + select.innerHTML = ''; + } +} + +function _selectAudioDevice(deviceIndex, isLoopback) { + const select = document.getElementById('audio-source-device'); + if (!select) return; + const val = `${deviceIndex ?? -1}:${isLoopback !== false ? '1' : '0'}`; + const opt = Array.from(select.options).find(o => o.value === val); + if (opt) select.value = val; +} + +function _loadMultichannelSources(selectedId) { + const select = document.getElementById('audio-source-parent'); + if (!select) return; + const multichannel = _cachedAudioSources.filter(s => s.source_type === 'multichannel'); + select.innerHTML = multichannel.map(s => + `` + ).join(''); +} diff --git a/server/src/wled_controller/static/js/features/color-strips.js b/server/src/wled_controller/static/js/features/color-strips.js index f732a38..c75b6dd 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -39,8 +39,9 @@ class CSSEditorModal extends Modal { effect_scale: document.getElementById('css-editor-effect-scale').value, effect_mirror: document.getElementById('css-editor-effect-mirror').checked, composite_layers: JSON.stringify(_compositeLayers), + mapped_zones: JSON.stringify(_mappedZones), audio_viz: document.getElementById('css-editor-audio-viz').value, - audio_device: document.getElementById('css-editor-audio-device').value, + audio_source: document.getElementById('css-editor-audio-source').value, audio_sensitivity: document.getElementById('css-editor-audio-sensitivity').value, audio_smoothing: document.getElementById('css-editor-audio-smoothing').value, audio_palette: document.getElementById('css-editor-audio-palette').value, @@ -63,6 +64,7 @@ export function onCSSTypeChange() { document.getElementById('css-editor-gradient-section').style.display = type === 'gradient' ? '' : 'none'; document.getElementById('css-editor-effect-section').style.display = type === 'effect' ? '' : 'none'; document.getElementById('css-editor-composite-section').style.display = type === 'composite' ? '' : 'none'; + document.getElementById('css-editor-mapped-section').style.display = type === 'mapped' ? '' : 'none'; document.getElementById('css-editor-audio-section').style.display = type === 'audio' ? '' : 'none'; if (type === 'effect') onEffectTypeChange(); @@ -97,14 +99,16 @@ export function onCSSTypeChange() { } _syncAnimationSpeedState(); - // LED count — not needed for composite/audio (uses device count) + // LED count — not needed for composite/mapped/audio (uses device count) document.getElementById('css-editor-led-count-group').style.display = - (type === 'composite' || type === 'audio') ? 'none' : ''; + (type === 'composite' || type === 'mapped' || type === 'audio') ? 'none' : ''; if (type === 'audio') { - _loadAudioDevices(); + _loadAudioSources(); } else if (type === 'composite') { _compositeRenderList(); + } else if (type === 'mapped') { + _mappedRenderList(); } else if (type === 'gradient') { requestAnimationFrame(() => gradientRenderAll()); } @@ -391,6 +395,107 @@ function _loadCompositeState(css) { _compositeRenderList(); } +/* ── Mapped zone helpers ──────────────────────────────────────── */ + +let _mappedZones = []; +let _mappedAvailableSources = []; // non-mapped sources for zone dropdowns + +function _mappedRenderList() { + const list = document.getElementById('mapped-zones-list'); + if (!list) return; + list.innerHTML = _mappedZones.map((zone, i) => { + const srcOptions = _mappedAvailableSources.map(s => + `` + ).join(''); + return ` +
+
+ #${i + 1} + +
+
+ +
+ + + + +
+ +
+
+ `; + }).join(''); +} + +export function mappedAddZone() { + _mappedZonesSyncFromDom(); + _mappedZones.push({ + source_id: _mappedAvailableSources.length > 0 ? _mappedAvailableSources[0].id : '', + start: 0, + end: 0, + reverse: false, + }); + _mappedRenderList(); +} + +export function mappedRemoveZone(i) { + _mappedZonesSyncFromDom(); + _mappedZones.splice(i, 1); + _mappedRenderList(); +} + +function _mappedZonesSyncFromDom() { + const list = document.getElementById('mapped-zones-list'); + if (!list) return; + const srcs = list.querySelectorAll('.mapped-zone-source'); + const starts = list.querySelectorAll('.mapped-zone-start'); + const ends = list.querySelectorAll('.mapped-zone-end'); + const reverses = list.querySelectorAll('.mapped-zone-reverse'); + if (srcs.length === _mappedZones.length) { + for (let i = 0; i < srcs.length; i++) { + _mappedZones[i].source_id = srcs[i].value; + _mappedZones[i].start = parseInt(starts[i].value) || 0; + _mappedZones[i].end = parseInt(ends[i].value) || 0; + _mappedZones[i].reverse = reverses[i].checked; + } + } +} + +function _mappedGetZones() { + _mappedZonesSyncFromDom(); + return _mappedZones.map(z => ({ + source_id: z.source_id, + start: z.start, + end: z.end, + reverse: z.reverse, + })); +} + +function _loadMappedState(css) { + const raw = css && css.zones; + _mappedZones = (raw && raw.length > 0) + ? raw.map(z => ({ + source_id: z.source_id || '', + start: z.start || 0, + end: z.end || 0, + reverse: z.reverse || false, + })) + : []; + _mappedRenderList(); +} + +function _resetMappedState() { + _mappedZones = []; + _mappedRenderList(); +} + /* ── Audio visualization helpers ──────────────────────────────── */ export function onAudioVizChange() { @@ -405,24 +510,22 @@ export function onAudioVizChange() { document.getElementById('css-editor-audio-mirror-group').style.display = viz === 'spectrum' ? '' : 'none'; } -async function _loadAudioDevices() { - const select = document.getElementById('css-editor-audio-device'); +async function _loadAudioSources() { + const select = document.getElementById('css-editor-audio-source'); if (!select) return; try { - const resp = await fetchWithAuth('/audio-devices'); + const resp = await fetchWithAuth('/audio-sources?source_type=mono'); if (!resp.ok) throw new Error('fetch failed'); const data = await resp.json(); - const devices = data.devices || []; - select.innerHTML = devices.map(d => { - const label = d.is_loopback ? `🔊 ${d.name}` : `🎤 ${d.name}`; - const val = `${d.index}:${d.is_loopback ? '1' : '0'}`; - return ``; - }).join(''); - if (devices.length === 0) { - select.innerHTML = ''; + const sources = data.sources || []; + select.innerHTML = sources.map(s => + `` + ).join(''); + if (sources.length === 0) { + select.innerHTML = ''; } } catch { - select.innerHTML = ''; + select.innerHTML = ''; } } @@ -438,21 +541,15 @@ function _loadAudioState(css) { document.getElementById('css-editor-audio-smoothing').value = smoothing; document.getElementById('css-editor-audio-smoothing-val').textContent = parseFloat(smoothing).toFixed(2); - document.getElementById('css-editor-audio-channel').value = css.audio_channel || 'mono'; document.getElementById('css-editor-audio-palette').value = css.palette || 'rainbow'; document.getElementById('css-editor-audio-color').value = rgbArrayToHex(css.color || [0, 255, 0]); document.getElementById('css-editor-audio-color-peak').value = rgbArrayToHex(css.color_peak || [255, 0, 0]); document.getElementById('css-editor-audio-mirror').checked = css.mirror || false; - // Set audio device selector to match stored values - const deviceIdx = css.audio_device_index ?? -1; - const loopback = css.audio_loopback !== false ? '1' : '0'; - const deviceVal = `${deviceIdx}:${loopback}`; - const select = document.getElementById('css-editor-audio-device'); - if (select) { - // Try exact match, fall back to first option - const opt = Array.from(select.options).find(o => o.value === deviceVal); - if (opt) select.value = deviceVal; + // Set audio source selector + const select = document.getElementById('css-editor-audio-source'); + if (select && css.audio_source_id) { + select.value = css.audio_source_id; } } @@ -462,7 +559,6 @@ function _resetAudioState() { document.getElementById('css-editor-audio-sensitivity-val').textContent = '1.0'; document.getElementById('css-editor-audio-smoothing').value = 0.3; document.getElementById('css-editor-audio-smoothing-val').textContent = '0.30'; - document.getElementById('css-editor-audio-channel').value = 'mono'; document.getElementById('css-editor-audio-palette').value = 'rainbow'; document.getElementById('css-editor-audio-color').value = '#00ff00'; document.getElementById('css-editor-audio-color-peak').value = '#ff0000'; @@ -477,6 +573,7 @@ export function createColorStripCard(source, pictureSourceMap) { const isColorCycle = source.source_type === 'color_cycle'; const isEffect = source.source_type === 'effect'; const isComposite = source.source_type === 'composite'; + const isMapped = source.source_type === 'mapped'; const isAudio = source.source_type === 'audio'; const anim = (isStatic || isGradient) && source.animation && source.animation.enabled ? source.animation : null; @@ -543,15 +640,20 @@ export function createColorStripCard(source, pictureSourceMap) { 🔗 ${enabledCount}/${layerCount} ${t('color_strip.composite.layers_count')} ${source.led_count ? `💡 ${source.led_count}` : ''} `; + } else if (isMapped) { + const zoneCount = (source.zones || []).length; + propsHtml = ` + 📍 ${zoneCount} ${t('color_strip.mapped.zones_count')} + ${source.led_count ? `💡 ${source.led_count}` : ''} + `; } else if (isAudio) { const vizLabel = t('color_strip.audio.viz.' + (source.visualization_mode || 'spectrum')) || source.visualization_mode || 'spectrum'; const sensitivityVal = (source.sensitivity || 1.0).toFixed(1); - const ch = source.audio_channel || 'mono'; - const chBadge = ch !== 'mono' ? `${ch === 'left' ? 'L' : 'R'}` : ''; + const srcLabel = source.audio_source_id || '—'; propsHtml = ` 🎵 ${escapeHtml(vizLabel)} 📶 ${sensitivityVal} - ${chBadge} + ${source.audio_source_id ? `🔊` : ''} ${source.mirror ? `🪞` : ''} `; } else { @@ -567,8 +669,8 @@ export function createColorStripCard(source, pictureSourceMap) { `; } - const icon = isStatic ? '🎨' : isColorCycle ? '🔄' : isGradient ? '🌈' : isEffect ? '⚡' : isComposite ? '🔗' : isAudio ? '🎵' : '🎞️'; - const calibrationBtn = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isAudio) + const icon = isStatic ? '🎨' : isColorCycle ? '🔄' : isGradient ? '🌈' : isEffect ? '⚡' : isComposite ? '🔗' : isMapped ? '📍' : isAudio ? '🎵' : '🎞️'; + const calibrationBtn = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isMapped && !isAudio) ? `` : ''; @@ -605,6 +707,9 @@ export async function showCSSEditor(cssId = null, cloneData = null) { _compositeAvailableSources = allCssSources.filter(s => s.source_type !== 'composite' && (!cssId || s.id !== cssId) ); + _mappedAvailableSources = allCssSources.filter(s => + s.source_type !== 'mapped' && (!cssId || s.id !== cssId) + ); const sourceSelect = document.getElementById('css-editor-picture-source'); sourceSelect.innerHTML = ''; @@ -648,10 +753,12 @@ export async function showCSSEditor(cssId = null, cloneData = null) { document.getElementById('css-editor-effect-scale-val').textContent = parseFloat(css.scale ?? 1.0).toFixed(1); document.getElementById('css-editor-effect-mirror').checked = css.mirror || false; } else if (sourceType === 'audio') { - await _loadAudioDevices(); + await _loadAudioSources(); _loadAudioState(css); } else if (sourceType === 'composite') { _loadCompositeState(css); + } else if (sourceType === 'mapped') { + _loadMappedState(css); } else { sourceSelect.value = css.picture_source_id || ''; @@ -687,11 +794,15 @@ export async function showCSSEditor(cssId = null, cloneData = null) { document.getElementById('css-editor-id').value = css.id; document.getElementById('css-editor-name').value = css.name; - // Exclude self from composite sources when editing + // Exclude self from composite/mapped sources when editing if (css.source_type === 'composite') { _compositeAvailableSources = allCssSources.filter(s => s.source_type !== 'composite' && s.id !== css.id ); + } else if (css.source_type === 'mapped') { + _mappedAvailableSources = allCssSources.filter(s => + s.source_type !== 'mapped' && s.id !== css.id + ); } await _populateFromCSS(css); @@ -731,6 +842,7 @@ export async function showCSSEditor(cssId = null, cloneData = null) { document.getElementById('css-editor-effect-scale-val').textContent = '1.0'; document.getElementById('css-editor-effect-mirror').checked = false; _loadCompositeState(null); + _resetMappedState(); _resetAudioState(); document.getElementById('css-editor-title').textContent = t('color_strip.add'); document.getElementById('css-editor-gradient-preset').value = ''; @@ -820,14 +932,10 @@ export async function saveCSSEditor() { } if (!cssId) payload.source_type = 'effect'; } else if (sourceType === 'audio') { - const deviceVal = document.getElementById('css-editor-audio-device').value || '-1:1'; - const [devIdx, devLoop] = deviceVal.split(':'); payload = { name, visualization_mode: document.getElementById('css-editor-audio-viz').value, - audio_device_index: parseInt(devIdx) || -1, - audio_loopback: devLoop !== '0', - audio_channel: document.getElementById('css-editor-audio-channel').value, + audio_source_id: document.getElementById('css-editor-audio-source').value || null, sensitivity: parseFloat(document.getElementById('css-editor-audio-sensitivity').value), smoothing: parseFloat(document.getElementById('css-editor-audio-smoothing').value), palette: document.getElementById('css-editor-audio-palette').value, @@ -852,6 +960,15 @@ export async function saveCSSEditor() { layers, }; if (!cssId) payload.source_type = 'composite'; + } else if (sourceType === 'mapped') { + const zones = _mappedGetZones(); + const hasEmpty = zones.some(z => !z.source_id); + if (hasEmpty) { + cssEditorModal.showError(t('color_strip.mapped.error.no_source')); + return; + } + payload = { name, zones }; + if (!cssId) payload.source_type = 'mapped'; } else { payload = { name, diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js index 57ad76c..674add4 100644 --- a/server/src/wled_controller/static/js/features/streams.js +++ b/server/src/wled_controller/static/js/features/streams.js @@ -18,6 +18,7 @@ import { _currentTestStreamId, set_currentTestStreamId, _currentTestPPTemplateId, set_currentTestPPTemplateId, _lastValidatedImageSource, set_lastValidatedImageSource, + _cachedAudioSources, set_cachedAudioSources, apiKey, } from '../core/state.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js'; @@ -436,11 +437,12 @@ export async function deleteTemplate(templateId) { export async function loadPictureSources() { try { - const [filtersResp, ppResp, captResp, streamsResp] = await Promise.all([ + const [filtersResp, ppResp, captResp, streamsResp, audioResp] = await Promise.all([ _availableFilters.length === 0 ? fetchWithAuth('/filters') : Promise.resolve(null), fetchWithAuth('/postprocessing-templates'), fetchWithAuth('/capture-templates'), - fetchWithAuth('/picture-sources') + fetchWithAuth('/picture-sources'), + fetchWithAuth('/audio-sources'), ]); if (filtersResp && filtersResp.ok) { @@ -455,6 +457,10 @@ export async function loadPictureSources() { const cd = await captResp.json(); set_cachedCaptureTemplates(cd.templates || []); } + if (audioResp && audioResp.ok) { + const ad = await audioResp.json(); + set_cachedAudioSources(ad.sources || []); + } if (!streamsResp.ok) throw new Error(`Failed to load streams: ${streamsResp.status}`); const data = await streamsResp.json(); set_cachedStreams(data.streams || []); @@ -596,21 +602,60 @@ function renderPictureSourcesList(streams) { const processedStreams = streams.filter(s => s.stream_type === 'processed'); const staticImageStreams = streams.filter(s => s.stream_type === 'static_image'); + const multichannelSources = _cachedAudioSources.filter(s => s.source_type === 'multichannel'); + const monoSources = _cachedAudioSources.filter(s => s.source_type === 'mono'); + const addStreamCard = (type) => `
+
`; const tabs = [ - { key: 'raw', icon: '🖥️', titleKey: 'streams.group.raw', streams: rawStreams }, - { key: 'static_image', icon: '🖼️', titleKey: 'streams.group.static_image', streams: staticImageStreams }, - { key: 'processed', icon: '🎨', titleKey: 'streams.group.processed', streams: processedStreams }, + { key: 'raw', icon: '🖥️', titleKey: 'streams.group.raw', count: rawStreams.length }, + { key: 'static_image', icon: '🖼️', titleKey: 'streams.group.static_image', count: staticImageStreams.length }, + { key: 'processed', icon: '🎨', titleKey: 'streams.group.processed', count: processedStreams.length }, + { key: 'audio', icon: '🔊', titleKey: 'streams.group.audio', count: _cachedAudioSources.length }, ]; const tabBar = `
${tabs.map(tab => - `` + `` ).join('')}
`; + const renderAudioSourceCard = (src) => { + const isMono = src.source_type === 'mono'; + const icon = isMono ? '🎤' : '🔊'; + + let propsHtml = ''; + if (isMono) { + const parent = _cachedAudioSources.find(s => s.id === src.audio_source_id); + const parentName = parent ? parent.name : src.audio_source_id; + const chLabel = src.channel === 'left' ? 'L' : src.channel === 'right' ? 'R' : 'M'; + propsHtml = ` + ${escapeHtml(parentName)} + ${chLabel} + `; + } else { + const devIdx = src.device_index ?? -1; + const loopback = src.is_loopback !== false; + const devLabel = loopback ? '🔊 Loopback' : '🎤 Input'; + propsHtml = `${devLabel} #${devIdx}`; + } + + return ` +
+ +
+
${icon} ${escapeHtml(src.name)}
+
+
${propsHtml}
+ ${src.description ? `
${escapeHtml(src.description)}
` : ''} +
+ +
+
+ `; + }; + const panels = tabs.map(tab => { let panelContent = ''; @@ -619,7 +664,7 @@ function renderPictureSourcesList(streams) {

${t('streams.section.streams')}

- ${tab.streams.map(renderStreamCard).join('')} + ${rawStreams.map(renderStreamCard).join('')} ${addStreamCard(tab.key)}
@@ -637,7 +682,7 @@ function renderPictureSourcesList(streams) {

${t('streams.section.streams')}

- ${tab.streams.map(renderStreamCard).join('')} + ${processedStreams.map(renderStreamCard).join('')} ${addStreamCard(tab.key)}
@@ -650,10 +695,30 @@ function renderPictureSourcesList(streams) { `; + } else if (tab.key === 'audio') { + panelContent = ` +
+

${t('audio_source.group.multichannel')}

+
+ ${multichannelSources.map(renderAudioSourceCard).join('')} +
+
+
+
+
+
+
+

${t('audio_source.group.mono')}

+
+ ${monoSources.map(renderAudioSourceCard).join('')} +
+
+
+
+
+
`; } else { panelContent = `
- ${tab.streams.map(renderStreamCard).join('')} + ${staticImageStreams.map(renderStreamCard).join('')} ${addStreamCard(tab.key)}
`; } diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index 0c05b69..b95b195 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -77,6 +77,56 @@ function _createTargetFpsChart(canvasId, history, fpsTarget, maxHwFps) { // --- Segment editor state --- let _editorCssSources = []; // populated when editor opens +/** + * When the selected CSS source is a mapped type, collapse the segment UI + * to a single source dropdown — range fields, reverse, header, and "Add Segment" + * are hidden because the mapped CSS already defines spatial zones internally. + */ +export function syncSegmentsMappedMode() { + const list = document.getElementById('target-editor-segment-list'); + if (!list) return; + const rows = list.querySelectorAll('.segment-row'); + if (rows.length === 0) return; + + const firstSelect = rows[0].querySelector('.segment-css-select'); + const selectedId = firstSelect ? firstSelect.value : ''; + const selectedSource = _editorCssSources.find(s => s.id === selectedId); + const isMapped = selectedSource && selectedSource.source_type === 'mapped'; + + // Remove extra segments when switching to mapped + if (isMapped && rows.length > 1) { + for (let i = rows.length - 1; i >= 1; i--) rows[i].remove(); + } + + // Toggle visibility of range/reverse/header within the first row + const firstRow = list.querySelector('.segment-row'); + if (firstRow) { + const header = firstRow.querySelector('.segment-row-header'); + const rangeFields = firstRow.querySelector('.segment-range-fields'); + const reverseLabel = firstRow.querySelector('.segment-reverse-label'); + if (header) header.style.display = isMapped ? 'none' : ''; + if (rangeFields) rangeFields.style.display = isMapped ? 'none' : ''; + if (reverseLabel) reverseLabel.style.display = isMapped ? 'none' : ''; + } + + // Hide/show "Add Segment" button + const addBtn = document.querySelector('#target-editor-segments-group > .btn-sm'); + if (addBtn) addBtn.style.display = isMapped ? 'none' : ''; + + // Swap label: "Segments:" ↔ "Color Strip Source:" + const group = document.getElementById('target-editor-segments-group'); + if (group) { + const label = group.querySelector('.label-row label'); + const hintToggle = group.querySelector('.hint-toggle'); + const hint = group.querySelector('.input-hint'); + if (label) label.textContent = isMapped + ? t('targets.color_strip_source') + : t('targets.segments'); + if (hintToggle) hintToggle.style.display = isMapped ? 'none' : ''; + if (hint) hint.style.display = 'none'; // collapse hint on switch + } +} + function _serializeSegments() { const rows = document.querySelectorAll('.segment-row'); const segments = []; @@ -195,7 +245,7 @@ function _renderSegmentRowInner(index, segment) {
- +
@@ -293,6 +343,8 @@ export async function showTargetEditor(targetId = null, cloneData = null) { addTargetSegment(); } + syncSegmentsMappedMode(); + // Auto-name generation _targetNameManuallyEdited = !!(targetId || cloneData); document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; }; diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 717f225..a9d4125 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -249,6 +249,7 @@ "streams.description": "Sources define the capture pipeline. A raw source captures from a display using a capture template. A processed source applies postprocessing to another source. Assign sources to devices.", "streams.group.raw": "Screen Capture", "streams.group.processed": "Processed", + "streams.group.audio": "Audio", "streams.section.streams": "\uD83D\uDCFA Sources", "streams.add": "Add Source", "streams.add.raw": "Add Screen Capture", @@ -365,6 +366,7 @@ "targets.device.hint": "Select the LED device to send data to", "targets.device.none": "-- Select a device --", "targets.segments": "Segments:", + "targets.color_strip_source": "Color Strip Source:", "targets.segments.hint": "Each segment maps a color strip source to a pixel range on the LED strip. Gaps between segments stay black. A single segment with Start=0, End=0 auto-fits to the full strip.", "targets.segments.add": "+ Add Segment", "targets.segment.start": "Start:", @@ -652,6 +654,8 @@ "color_strip.type.effect.hint": "Procedural LED effects (fire, meteor, plasma, noise, aurora) generated in real time.", "color_strip.type.composite": "Composite", "color_strip.type.composite.hint": "Stack multiple color strip sources as layers with blend modes and opacity.", + "color_strip.type.mapped": "Mapped", + "color_strip.type.mapped.hint": "Assign different color strip sources to different LED ranges (zones). Unlike composite which blends layers, mapped places sources side-by-side.", "color_strip.type.audio": "Audio Reactive", "color_strip.type.audio.hint": "LED colors driven by real-time audio input — system audio or microphone.", "color_strip.composite.layers": "Layers:", @@ -668,18 +672,22 @@ "color_strip.composite.error.min_layers": "At least 1 layer is required", "color_strip.composite.error.no_source": "Each layer must have a source selected", "color_strip.composite.layers_count": "layers", + "color_strip.mapped.zones": "Zones:", + "color_strip.mapped.zones.hint": "Each zone maps a color strip source to a specific LED range. Zones are placed side-by-side — gaps between zones stay black.", + "color_strip.mapped.add_zone": "+ Add Zone", + "color_strip.mapped.zone_source": "Source", + "color_strip.mapped.zone_start": "Start LED", + "color_strip.mapped.zone_end": "End LED", + "color_strip.mapped.zone_reverse": "Reverse", + "color_strip.mapped.zones_count": "zones", + "color_strip.mapped.error.no_source": "Each zone must have a source selected", "color_strip.audio.visualization": "Visualization:", "color_strip.audio.visualization.hint": "How audio data is rendered to LEDs.", "color_strip.audio.viz.spectrum": "Spectrum Analyzer", "color_strip.audio.viz.beat_pulse": "Beat Pulse", "color_strip.audio.viz.vu_meter": "VU Meter", - "color_strip.audio.device": "Audio Device:", - "color_strip.audio.device.hint": "Audio input source. Loopback devices capture system audio output; input devices capture microphone or line-in.", - "color_strip.audio.channel": "Channel:", - "color_strip.audio.channel.hint": "Select which audio channel to visualize. Use Left/Right for stereo setups.", - "color_strip.audio.channel.mono": "Mono (L+R mix)", - "color_strip.audio.channel.left": "Left", - "color_strip.audio.channel.right": "Right", + "color_strip.audio.source": "Audio Source:", + "color_strip.audio.source.hint": "Mono audio source that provides audio data for this visualization. Create and manage audio sources in the Sources tab.", "color_strip.audio.sensitivity": "Sensitivity:", "color_strip.audio.sensitivity.hint": "Gain multiplier for audio levels. Higher values make LEDs react to quieter sounds.", "color_strip.audio.smoothing": "Smoothing:", @@ -723,5 +731,39 @@ "color_strip.palette.rainbow": "Rainbow", "color_strip.palette.aurora": "Aurora", "color_strip.palette.sunset": "Sunset", - "color_strip.palette.ice": "Ice" + "color_strip.palette.ice": "Ice", + + "audio_source.title": "Audio Sources", + "audio_source.group.multichannel": "Multichannel", + "audio_source.group.mono": "Mono", + "audio_source.add": "Add Audio Source", + "audio_source.add.multichannel": "Add Multichannel Source", + "audio_source.add.mono": "Add Mono Source", + "audio_source.edit": "Edit Audio Source", + "audio_source.edit.multichannel": "Edit Multichannel Source", + "audio_source.edit.mono": "Edit Mono Source", + "audio_source.name": "Name:", + "audio_source.name.placeholder": "System Audio", + "audio_source.name.hint": "A descriptive name for this audio source", + "audio_source.type": "Type:", + "audio_source.type.hint": "Multichannel captures all channels from a physical audio device. Mono extracts a single channel from a multichannel source.", + "audio_source.type.multichannel": "Multichannel", + "audio_source.type.mono": "Mono", + "audio_source.device": "Audio Device:", + "audio_source.device.hint": "Audio input source. Loopback devices capture system audio output; input devices capture microphone or line-in.", + "audio_source.parent": "Parent Source:", + "audio_source.parent.hint": "Multichannel source to extract a channel from", + "audio_source.channel": "Channel:", + "audio_source.channel.hint": "Which audio channel to extract from the multichannel source", + "audio_source.channel.mono": "Mono (L+R mix)", + "audio_source.channel.left": "Left", + "audio_source.channel.right": "Right", + "audio_source.description": "Description (optional):", + "audio_source.description.placeholder": "Describe this audio source...", + "audio_source.description.hint": "Optional notes about this audio source", + "audio_source.created": "Audio source created", + "audio_source.updated": "Audio source updated", + "audio_source.deleted": "Audio source deleted", + "audio_source.delete.confirm": "Are you sure you want to delete this audio source?", + "audio_source.error.name_required": "Please enter a name" } diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 2206c51..1b3a9c6 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -249,6 +249,7 @@ "streams.description": "Источники определяют конвейер захвата. Сырой источник захватывает экран с помощью шаблона захвата. Обработанный источник применяет постобработку к другому источнику. Назначайте источники устройствам.", "streams.group.raw": "Захват Экрана", "streams.group.processed": "Обработанные", + "streams.group.audio": "Аудио", "streams.section.streams": "\uD83D\uDCFA Источники", "streams.add": "Добавить Источник", "streams.add.raw": "Добавить Захват Экрана", @@ -365,6 +366,7 @@ "targets.device.hint": "Выберите LED устройство для передачи данных", "targets.device.none": "-- Выберите устройство --", "targets.segments": "Сегменты:", + "targets.color_strip_source": "Источник цветовой полосы:", "targets.segments.hint": "Каждый сегмент отображает источник цветовой полосы на диапазон пикселей LED ленты. Промежутки между сегментами остаются чёрными. Один сегмент с Начало=0, Конец=0 авто-подгоняется под всю ленту.", "targets.segments.add": "+ Добавить сегмент", "targets.segment.start": "Начало:", @@ -652,6 +654,8 @@ "color_strip.type.effect.hint": "Процедурные LED-эффекты (огонь, метеор, плазма, шум, аврора), генерируемые в реальном времени.", "color_strip.type.composite": "Композит", "color_strip.type.composite.hint": "Наложение нескольких источников цветовой ленты как слоёв с режимами смешивания и прозрачностью.", + "color_strip.type.mapped": "Маппинг", + "color_strip.type.mapped.hint": "Назначает разные источники цветовой полосы на разные диапазоны LED (зоны). В отличие от композита, маппинг размещает источники рядом друг с другом.", "color_strip.type.audio": "Аудиореактив", "color_strip.type.audio.hint": "Цвета LED управляются аудиосигналом в реальном времени — системный звук или микрофон.", "color_strip.composite.layers": "Слои:", @@ -668,18 +672,22 @@ "color_strip.composite.error.min_layers": "Необходим хотя бы 1 слой", "color_strip.composite.error.no_source": "Для каждого слоя должен быть выбран источник", "color_strip.composite.layers_count": "слоёв", + "color_strip.mapped.zones": "Зоны:", + "color_strip.mapped.zones.hint": "Каждая зона привязывает источник цветовой полосы к определённому диапазону LED. Зоны размещаются рядом — промежутки остаются чёрными.", + "color_strip.mapped.add_zone": "+ Добавить зону", + "color_strip.mapped.zone_source": "Источник", + "color_strip.mapped.zone_start": "Начало LED", + "color_strip.mapped.zone_end": "Конец LED", + "color_strip.mapped.zone_reverse": "Реверс", + "color_strip.mapped.zones_count": "зон", + "color_strip.mapped.error.no_source": "Для каждой зоны должен быть выбран источник", "color_strip.audio.visualization": "Визуализация:", "color_strip.audio.visualization.hint": "Способ отображения аудиоданных на LED.", "color_strip.audio.viz.spectrum": "Анализатор спектра", "color_strip.audio.viz.beat_pulse": "Пульс бита", "color_strip.audio.viz.vu_meter": "VU-метр", - "color_strip.audio.device": "Аудиоустройство:", - "color_strip.audio.device.hint": "Источник аудиосигнала. Устройства обратной петли захватывают системный звук; устройства ввода — микрофон или линейный вход.", - "color_strip.audio.channel": "Канал:", - "color_strip.audio.channel.hint": "Какой аудиоканал визуализировать. Используйте Левый/Правый для стерео-режима.", - "color_strip.audio.channel.mono": "Моно (Л+П микс)", - "color_strip.audio.channel.left": "Левый", - "color_strip.audio.channel.right": "Правый", + "color_strip.audio.source": "Аудиоисточник:", + "color_strip.audio.source.hint": "Моно-аудиоисточник, предоставляющий аудиоданные для визуализации. Создавайте и управляйте аудиоисточниками на вкладке Источники.", "color_strip.audio.sensitivity": "Чувствительность:", "color_strip.audio.sensitivity.hint": "Множитель усиления аудиосигнала. Более высокие значения делают LED чувствительнее к тихим звукам.", "color_strip.audio.smoothing": "Сглаживание:", @@ -723,5 +731,39 @@ "color_strip.palette.rainbow": "Радуга", "color_strip.palette.aurora": "Аврора", "color_strip.palette.sunset": "Закат", - "color_strip.palette.ice": "Лёд" + "color_strip.palette.ice": "Лёд", + + "audio_source.title": "Аудиоисточники", + "audio_source.group.multichannel": "Многоканальные", + "audio_source.group.mono": "Моно", + "audio_source.add": "Добавить аудиоисточник", + "audio_source.add.multichannel": "Добавить многоканальный", + "audio_source.add.mono": "Добавить моно", + "audio_source.edit": "Редактировать аудиоисточник", + "audio_source.edit.multichannel": "Редактировать многоканальный", + "audio_source.edit.mono": "Редактировать моно", + "audio_source.name": "Название:", + "audio_source.name.placeholder": "Системный звук", + "audio_source.name.hint": "Описательное имя для этого аудиоисточника", + "audio_source.type": "Тип:", + "audio_source.type.hint": "Многоканальный захватывает все каналы с аудиоустройства. Моно извлекает один канал из многоканального источника.", + "audio_source.type.multichannel": "Многоканальный", + "audio_source.type.mono": "Моно", + "audio_source.device": "Аудиоустройство:", + "audio_source.device.hint": "Источник аудиосигнала. Устройства обратной петли захватывают системный звук; устройства ввода — микрофон или линейный вход.", + "audio_source.parent": "Родительский источник:", + "audio_source.parent.hint": "Многоканальный источник для извлечения канала", + "audio_source.channel": "Канал:", + "audio_source.channel.hint": "Какой аудиоканал извлечь из многоканального источника", + "audio_source.channel.mono": "Моно (Л+П микс)", + "audio_source.channel.left": "Левый", + "audio_source.channel.right": "Правый", + "audio_source.description": "Описание (необязательно):", + "audio_source.description.placeholder": "Опишите этот аудиоисточник...", + "audio_source.description.hint": "Необязательные заметки об этом аудиоисточнике", + "audio_source.created": "Аудиоисточник создан", + "audio_source.updated": "Аудиоисточник обновлён", + "audio_source.deleted": "Аудиоисточник удалён", + "audio_source.delete.confirm": "Удалить этот аудиоисточник?", + "audio_source.error.name_required": "Введите название" } diff --git a/server/src/wled_controller/storage/audio_source.py b/server/src/wled_controller/storage/audio_source.py new file mode 100644 index 0000000..092967a --- /dev/null +++ b/server/src/wled_controller/storage/audio_source.py @@ -0,0 +1,113 @@ +"""Audio source data model with inheritance-based source types. + +An AudioSource represents a reusable audio input configuration: + MultichannelAudioSource — wraps a physical audio device (index + loopback flag) + MonoAudioSource — extracts a single channel from a multichannel source +""" + +from dataclasses import dataclass +from datetime import datetime +from typing import Optional + + +@dataclass +class AudioSource: + """Base class for audio source configurations.""" + + id: str + name: str + source_type: str # "multichannel" | "mono" + 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 + "device_index": None, + "is_loopback": None, + "audio_source_id": None, + "channel": None, + } + + @staticmethod + def from_dict(data: dict) -> "AudioSource": + """Factory: dispatch to the correct subclass based on source_type.""" + source_type: str = data.get("source_type", "multichannel") or "multichannel" + 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 == "mono": + return MonoAudioSource( + id=sid, name=name, source_type="mono", + created_at=created_at, updated_at=updated_at, description=description, + audio_source_id=data.get("audio_source_id") or "", + channel=data.get("channel") or "mono", + ) + + # Default: multichannel + return MultichannelAudioSource( + id=sid, name=name, source_type="multichannel", + created_at=created_at, updated_at=updated_at, description=description, + device_index=int(data.get("device_index", -1)), + is_loopback=bool(data.get("is_loopback", True)), + ) + + +@dataclass +class MultichannelAudioSource(AudioSource): + """Audio source wrapping a physical audio device. + + Captures all channels from the device. For WASAPI loopback devices + (system audio output), set is_loopback=True. + """ + + device_index: int = -1 # -1 = default device + is_loopback: bool = True # True = WASAPI loopback (system audio) + + def to_dict(self) -> dict: + d = super().to_dict() + d["device_index"] = self.device_index + d["is_loopback"] = self.is_loopback + return d + + +@dataclass +class MonoAudioSource(AudioSource): + """Audio source that extracts a single channel from a multichannel source. + + References a MultichannelAudioSource and selects which channel to use: + mono (L+R mix), left, or right. + """ + + audio_source_id: str = "" # references a MultichannelAudioSource + channel: str = "mono" # mono | left | right + + def to_dict(self) -> dict: + d = super().to_dict() + d["audio_source_id"] = self.audio_source_id + d["channel"] = self.channel + return d diff --git a/server/src/wled_controller/storage/audio_source_store.py b/server/src/wled_controller/storage/audio_source_store.py new file mode 100644 index 0000000..a87ee1e --- /dev/null +++ b/server/src/wled_controller/storage/audio_source_store.py @@ -0,0 +1,324 @@ +"""Audio source storage using JSON files.""" + +import json +import uuid +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +from wled_controller.storage.audio_source import ( + AudioSource, + MonoAudioSource, + MultichannelAudioSource, +) +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + + +class AudioSourceStore: + """Persistent storage for audio sources.""" + + def __init__(self, file_path: str): + self.file_path = Path(file_path) + self._sources: Dict[str, AudioSource] = {} + self._load() + + def _load(self) -> None: + if not self.file_path.exists(): + logger.info("Audio 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("audio_sources", {}) + loaded = 0 + for source_id, source_dict in sources_data.items(): + try: + source = AudioSource.from_dict(source_dict) + self._sources[source_id] = source + loaded += 1 + except Exception as e: + logger.error( + f"Failed to load audio source {source_id}: {e}", + exc_info=True, + ) + + if loaded > 0: + logger.info(f"Loaded {loaded} audio sources from storage") + + except Exception as e: + logger.error(f"Failed to load audio sources from {self.file_path}: {e}") + raise + + logger.info(f"Audio 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", + "audio_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 audio sources to {self.file_path}: {e}") + raise + + # ── CRUD ───────────────────────────────────────────────────────── + + def get_all_sources(self) -> List[AudioSource]: + return list(self._sources.values()) + + def get_mono_sources(self) -> List[MonoAudioSource]: + """Return only mono audio sources (for CSS dropdown).""" + return [s for s in self._sources.values() if isinstance(s, MonoAudioSource)] + + def get_source(self, source_id: str) -> AudioSource: + if source_id not in self._sources: + raise ValueError(f"Audio source not found: {source_id}") + return self._sources[source_id] + + def create_source( + self, + name: str, + source_type: str, + device_index: Optional[int] = None, + is_loopback: Optional[bool] = None, + audio_source_id: Optional[str] = None, + channel: Optional[str] = None, + description: Optional[str] = None, + ) -> AudioSource: + if not name or not name.strip(): + raise ValueError("Name is required") + + if source_type not in ("multichannel", "mono"): + raise ValueError(f"Invalid source type: {source_type}") + + for source in self._sources.values(): + if source.name == name: + raise ValueError(f"Audio source with name '{name}' already exists") + + sid = f"as_{uuid.uuid4().hex[:8]}" + now = datetime.utcnow() + + if source_type == "mono": + if not audio_source_id: + raise ValueError("Mono sources require audio_source_id") + # Validate parent exists and is multichannel + parent = self._sources.get(audio_source_id) + if not parent: + raise ValueError(f"Parent audio source not found: {audio_source_id}") + if not isinstance(parent, MultichannelAudioSource): + raise ValueError("Mono sources must reference a multichannel source") + + source = MonoAudioSource( + id=sid, name=name, source_type="mono", + created_at=now, updated_at=now, description=description, + audio_source_id=audio_source_id, + channel=channel or "mono", + ) + else: + source = MultichannelAudioSource( + id=sid, name=name, source_type="multichannel", + created_at=now, updated_at=now, description=description, + device_index=device_index if device_index is not None else -1, + is_loopback=bool(is_loopback) if is_loopback is not None else True, + ) + + self._sources[sid] = source + self._save() + + logger.info(f"Created audio source: {name} ({sid}, type={source_type})") + return source + + def update_source( + self, + source_id: str, + name: Optional[str] = None, + device_index: Optional[int] = None, + is_loopback: Optional[bool] = None, + audio_source_id: Optional[str] = None, + channel: Optional[str] = None, + description: Optional[str] = None, + ) -> AudioSource: + if source_id not in self._sources: + raise ValueError(f"Audio 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"Audio source with name '{name}' already exists") + source.name = name + + if description is not None: + source.description = description + + if isinstance(source, MultichannelAudioSource): + if device_index is not None: + source.device_index = device_index + if is_loopback is not None: + source.is_loopback = bool(is_loopback) + elif isinstance(source, MonoAudioSource): + if audio_source_id is not None: + parent = self._sources.get(audio_source_id) + if not parent: + raise ValueError(f"Parent audio source not found: {audio_source_id}") + if not isinstance(parent, MultichannelAudioSource): + raise ValueError("Mono sources must reference a multichannel source") + source.audio_source_id = audio_source_id + if channel is not None: + source.channel = channel + + source.updated_at = datetime.utcnow() + self._save() + + logger.info(f"Updated audio source: {source_id}") + return source + + def delete_source(self, source_id: str) -> None: + if source_id not in self._sources: + raise ValueError(f"Audio source not found: {source_id}") + + source = self._sources[source_id] + + # Prevent deleting multichannel sources referenced by mono sources + if isinstance(source, MultichannelAudioSource): + for other in self._sources.values(): + if isinstance(other, MonoAudioSource) and other.audio_source_id == source_id: + raise ValueError( + f"Cannot delete '{source.name}': referenced by mono source '{other.name}'" + ) + + del self._sources[source_id] + self._save() + + logger.info(f"Deleted audio source: {source_id}") + + # ── Resolution ─────────────────────────────────────────────────── + + def resolve_mono_source(self, mono_id: str) -> Tuple[int, bool, str]: + """Resolve a mono audio source to (device_index, is_loopback, channel). + + Follows the reference chain: mono → multichannel. + + Raises: + ValueError: If source not found or chain is broken + """ + mono = self.get_source(mono_id) + if not isinstance(mono, MonoAudioSource): + raise ValueError(f"Audio source {mono_id} is not a mono source") + + parent = self.get_source(mono.audio_source_id) + if not isinstance(parent, MultichannelAudioSource): + raise ValueError( + f"Mono source {mono_id} references non-multichannel source {mono.audio_source_id}" + ) + + return parent.device_index, parent.is_loopback, mono.channel + + # ── Migration ──────────────────────────────────────────────────── + + def migrate_from_css(self, color_strip_store) -> None: + """One-time migration: extract audio config from existing CSS entities. + + For each AudioColorStripSource that has old-style embedded audio fields + (audio_device_index, audio_loopback, audio_channel) but no audio_source_id: + 1. Create a MultichannelAudioSource if one with matching config doesn't exist + 2. Create a MonoAudioSource referencing it + 3. Set audio_source_id on the CSS entity + 4. Save both stores + """ + from wled_controller.storage.color_strip_source import AudioColorStripSource + + migrated = 0 + multichannel_cache: Dict[Tuple[int, bool], str] = {} # (dev, loopback) → id + + # Index existing multichannel sources for dedup + for source in self._sources.values(): + if isinstance(source, MultichannelAudioSource): + key = (source.device_index, source.is_loopback) + multichannel_cache[key] = source.id + + for css in color_strip_store.get_all_sources(): + if not isinstance(css, AudioColorStripSource): + continue + # Skip if already migrated + if getattr(css, "audio_source_id", None): + continue + # Skip if no old fields present + if not hasattr(css, "audio_device_index"): + continue + + dev_idx = getattr(css, "audio_device_index", -1) + loopback = bool(getattr(css, "audio_loopback", True)) + channel = getattr(css, "audio_channel", "mono") or "mono" + + # Find or create multichannel source + mc_key = (dev_idx, loopback) + if mc_key in multichannel_cache: + mc_id = multichannel_cache[mc_key] + else: + device_label = "Loopback" if loopback else "Input" + mc_name = f"Audio Device {dev_idx} ({device_label})" + # Ensure unique name + suffix = 2 + base_name = mc_name + while any(s.name == mc_name for s in self._sources.values()): + mc_name = f"{base_name} #{suffix}" + suffix += 1 + + mc_id = f"as_{uuid.uuid4().hex[:8]}" + now = datetime.utcnow() + mc_source = MultichannelAudioSource( + id=mc_id, name=mc_name, source_type="multichannel", + created_at=now, updated_at=now, + device_index=dev_idx, is_loopback=loopback, + ) + self._sources[mc_id] = mc_source + multichannel_cache[mc_key] = mc_id + logger.info(f"Migration: created multichannel source '{mc_name}' ({mc_id})") + + # Create mono source + channel_label = {"mono": "Mono", "left": "Left", "right": "Right"}.get(channel, channel) + mono_name = f"{css.name} - {channel_label}" + # Ensure unique name + suffix = 2 + base_name = mono_name + while any(s.name == mono_name for s in self._sources.values()): + mono_name = f"{base_name} #{suffix}" + suffix += 1 + + mono_id = f"as_{uuid.uuid4().hex[:8]}" + now = datetime.utcnow() + mono_source = MonoAudioSource( + id=mono_id, name=mono_name, source_type="mono", + created_at=now, updated_at=now, + audio_source_id=mc_id, channel=channel, + ) + self._sources[mono_id] = mono_source + logger.info(f"Migration: created mono source '{mono_name}' ({mono_id})") + + # Update CSS entity + css.audio_source_id = mono_id + migrated += 1 + + if migrated > 0: + self._save() + color_strip_store._save() + logger.info(f"Migration complete: migrated {migrated} audio CSS entities") + else: + logger.debug("No audio CSS entities needed migration") diff --git a/server/src/wled_controller/storage/color_strip_source.py b/server/src/wled_controller/storage/color_strip_source.py index 1da1a7c..61c4fdd 100644 --- a/server/src/wled_controller/storage/color_strip_source.py +++ b/server/src/wled_controller/storage/color_strip_source.py @@ -73,12 +73,11 @@ class ColorStripSource: "scale": None, "mirror": None, "layers": None, + "zones": None, "visualization_mode": None, - "audio_device_index": None, - "audio_loopback": None, + "audio_source_id": None, "sensitivity": None, "color_peak": None, - "audio_channel": None, } @staticmethod @@ -155,6 +154,14 @@ class ColorStripSource: led_count=data.get("led_count") or 0, ) + if source_type == "mapped": + return MappedColorStripSource( + id=sid, name=name, source_type="mapped", + created_at=created_at, updated_at=updated_at, description=description, + zones=data.get("zones") or [], + led_count=data.get("led_count") or 0, + ) + if source_type == "audio": raw_color = data.get("color") color = raw_color if isinstance(raw_color, list) and len(raw_color) == 3 else [0, 255, 0] @@ -164,9 +171,7 @@ class ColorStripSource: id=sid, name=name, source_type="audio", created_at=created_at, updated_at=updated_at, description=description, visualization_mode=data.get("visualization_mode") or "spectrum", - audio_device_index=int(data.get("audio_device_index", -1)), - audio_loopback=bool(data.get("audio_loopback", True)), - audio_channel=data.get("audio_channel") or "mono", + audio_source_id=data.get("audio_source_id") or "", sensitivity=float(data.get("sensitivity") or 1.0), smoothing=float(data.get("smoothing") or 0.3), palette=data.get("palette") or "rainbow", @@ -366,9 +371,7 @@ class AudioColorStripSource(ColorStripSource): """ visualization_mode: str = "spectrum" # spectrum | beat_pulse | vu_meter - audio_device_index: int = -1 # -1 = default input device - audio_loopback: bool = True # True = WASAPI loopback (system audio) - audio_channel: str = "mono" # mono | left | right + audio_source_id: str = "" # references a MonoAudioSource sensitivity: float = 1.0 # gain multiplier (0.1–5.0) smoothing: float = 0.3 # temporal smoothing (0.0–1.0) palette: str = "rainbow" # named color palette @@ -380,9 +383,7 @@ class AudioColorStripSource(ColorStripSource): def to_dict(self) -> dict: d = super().to_dict() d["visualization_mode"] = self.visualization_mode - d["audio_device_index"] = self.audio_device_index - d["audio_loopback"] = self.audio_loopback - d["audio_channel"] = self.audio_channel + d["audio_source_id"] = self.audio_source_id d["sensitivity"] = self.sensitivity d["smoothing"] = self.smoothing d["palette"] = self.palette @@ -411,3 +412,24 @@ class CompositeColorStripSource(ColorStripSource): d["layers"] = [dict(layer) for layer in self.layers] d["led_count"] = self.led_count return d + + +@dataclass +class MappedColorStripSource(ColorStripSource): + """Color strip source that maps different sources to different LED ranges. + + Each zone assigns a sub-range of LEDs to a different color strip source. + Zones are placed side-by-side (spatial multiplexing) rather than blended. + Gaps between zones stay black. LED count auto-sizes from the connected + device when led_count == 0. + """ + + # Each zone: {"source_id": str, "start": int, "end": int, "reverse": bool} + zones: list = field(default_factory=list) + led_count: int = 0 # 0 = use device LED count + + def to_dict(self) -> dict: + d = super().to_dict() + d["zones"] = [dict(z) for z in self.zones] + d["led_count"] = self.led_count + return d diff --git a/server/src/wled_controller/storage/color_strip_store.py b/server/src/wled_controller/storage/color_strip_store.py index 41615f5..7274fec 100644 --- a/server/src/wled_controller/storage/color_strip_store.py +++ b/server/src/wled_controller/storage/color_strip_store.py @@ -14,6 +14,7 @@ from wled_controller.storage.color_strip_source import ( CompositeColorStripSource, EffectColorStripSource, GradientColorStripSource, + MappedColorStripSource, PictureColorStripSource, StaticColorStripSource, ) @@ -119,10 +120,9 @@ class ColorStripStore: scale: float = 1.0, mirror: bool = False, layers: Optional[list] = None, + zones: Optional[list] = None, visualization_mode: str = "spectrum", - audio_device_index: int = -1, - audio_loopback: bool = True, - audio_channel: str = "mono", + audio_source_id: str = "", sensitivity: float = 1.0, color_peak: Optional[list] = None, ) -> ColorStripSource: @@ -214,9 +214,7 @@ class ColorStripStore: updated_at=now, description=description, visualization_mode=visualization_mode or "spectrum", - audio_device_index=audio_device_index if audio_device_index is not None else -1, - audio_loopback=bool(audio_loopback), - audio_channel=audio_channel or "mono", + audio_source_id=audio_source_id or "", sensitivity=float(sensitivity) if sensitivity else 1.0, smoothing=float(smoothing) if smoothing else 0.3, palette=palette or "rainbow", @@ -236,6 +234,17 @@ class ColorStripStore: layers=layers if isinstance(layers, list) else [], led_count=led_count, ) + elif source_type == "mapped": + source = MappedColorStripSource( + id=source_id, + name=name, + source_type="mapped", + created_at=now, + updated_at=now, + description=description, + zones=zones if isinstance(zones, list) else [], + led_count=led_count, + ) else: if calibration is None: calibration = CalibrationConfig(layout="clockwise", start_position="bottom_left") @@ -291,10 +300,9 @@ class ColorStripStore: scale: Optional[float] = None, mirror: Optional[bool] = None, layers: Optional[list] = None, + zones: Optional[list] = None, visualization_mode: Optional[str] = None, - audio_device_index: Optional[int] = None, - audio_loopback: Optional[bool] = None, - audio_channel: Optional[str] = None, + audio_source_id: Optional[str] = None, sensitivity: Optional[float] = None, color_peak: Optional[list] = None, ) -> ColorStripSource: @@ -380,12 +388,8 @@ class ColorStripStore: elif isinstance(source, AudioColorStripSource): if visualization_mode is not None: source.visualization_mode = visualization_mode - if audio_device_index is not None: - source.audio_device_index = audio_device_index - if audio_loopback is not None: - source.audio_loopback = bool(audio_loopback) - if audio_channel is not None: - source.audio_channel = audio_channel + if audio_source_id is not None: + source.audio_source_id = audio_source_id if sensitivity is not None: source.sensitivity = float(sensitivity) if smoothing is not None: @@ -405,6 +409,11 @@ class ColorStripStore: source.layers = layers if led_count is not None: source.led_count = led_count + elif isinstance(source, MappedColorStripSource): + if zones is not None and isinstance(zones, list): + source.zones = zones + if led_count is not None: + source.led_count = led_count source.updated_at = datetime.utcnow() self._save() @@ -436,3 +445,14 @@ class ColorStripStore: names.append(source.name) break return names + + def get_mapped_referencing(self, source_id: str) -> List[str]: + """Return names of mapped sources that reference a given source as a zone.""" + names = [] + for source in self._sources.values(): + if isinstance(source, MappedColorStripSource): + for zone in source.zones: + if zone.get("source_id") == source_id: + names.append(source.name) + break + return names diff --git a/server/src/wled_controller/templates/index.html b/server/src/wled_controller/templates/index.html index a6b8a80..37b0d01 100644 --- a/server/src/wled_controller/templates/index.html +++ b/server/src/wled_controller/templates/index.html @@ -31,14 +31,14 @@ - - -
@@ -117,6 +117,7 @@ {% include 'modals/stream.html' %} {% include 'modals/pp-template.html' %} {% include 'modals/profile-editor.html' %} + {% include 'modals/audio-source-editor.html' %} {% include 'partials/tutorial-overlay.html' %} {% include 'partials/image-lightbox.html' %} diff --git a/server/src/wled_controller/templates/modals/audio-source-editor.html b/server/src/wled_controller/templates/modals/audio-source-editor.html new file mode 100644 index 0000000..c51f77c --- /dev/null +++ b/server/src/wled_controller/templates/modals/audio-source-editor.html @@ -0,0 +1,94 @@ + + diff --git a/server/src/wled_controller/templates/modals/css-editor.html b/server/src/wled_controller/templates/modals/css-editor.html index cc032ee..7021a35 100644 --- a/server/src/wled_controller/templates/modals/css-editor.html +++ b/server/src/wled_controller/templates/modals/css-editor.html @@ -27,6 +27,7 @@ +
@@ -316,6 +317,19 @@ + + +