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 `${escapeHtml(label)} `;
+ }).join('');
+ if (devices.length === 0) {
+ select.innerHTML = 'Default ';
+ }
+ } catch {
+ select.innerHTML = 'Default ';
+ }
+}
+
+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 =>
+ `${escapeHtml(s.name)} `
+ ).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 =>
+ `${escapeHtml(s.name)} `
+ ).join('');
+ return `
+
+ `;
+ }).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 `${escapeHtml(label)} `;
- }).join('');
- if (devices.length === 0) {
- select.innerHTML = 'Default ';
+ const sources = data.sources || [];
+ select.innerHTML = sources.map(s =>
+ `${escapeHtml(s.name)} `
+ ).join('');
+ if (sources.length === 0) {
+ select.innerHTML = '';
}
} catch {
- select.innerHTML = 'Default ';
+ 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 =>
- `${tab.icon} ${t(tab.titleKey)} ${tab.streams.length} `
+ `${tab.icon} ${t(tab.titleKey)} ${tab.count} `
).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 `
+
+
✕
+
+
${propsHtml}
+ ${src.description ? `
${escapeHtml(src.description)}
` : ''}
+
+ ✏️
+
+
+ `;
+ };
+
const panels = tabs.map(tab => {
let panelContent = '';
@@ -619,7 +664,7 @@ function renderPictureSourcesList(streams) {
- ${tab.streams.map(renderStreamCard).join('')}
+ ${rawStreams.map(renderStreamCard).join('')}
${addStreamCard(tab.key)}
@@ -637,7 +682,7 @@ function renderPictureSourcesList(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 = `
+
+
+
+ ${multichannelSources.map(renderAudioSourceCard).join('')}
+
+
+
+
+
+
+ ${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) {
×
-
${options}
+
${options}
${t('targets.segment.start')}
@@ -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 @@
🌙
-
+
English
Русский
-
+
🔑 Login
-
+
🚪
@@ -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 @@
Color Cycle
Procedural Effect
Composite
+
Mapped
Audio Reactive
@@ -316,6 +317,19 @@
+
+
+