Add audio sources as first-class entities, add mapped CSS type, simplify target editor for mapped sources

- Audio sources moved to separate tab with dedicated CRUD API, store, and editor modal
- New "mapped" color strip source type: assigns different CSS sources to distinct LED sub-ranges (zones)
- Mapped stream runtime with per-zone sub-streams, auto-sizing, hot-update support
- Target editor auto-collapses segments UI when mapped CSS is selected
- Delete protection for CSS sources referenced by mapped zones
- Compact header/footer layout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 23:35:58 +03:00
parent 199039326b
commit 9efb08acb6
28 changed files with 1729 additions and 153 deletions

View File

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

View File

@@ -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_source_store import PictureSourceStore
from wled_controller.storage.picture_target_store import PictureTargetStore from wled_controller.storage.picture_target_store import PictureTargetStore
from wled_controller.storage.color_strip_store import ColorStripStore from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.audio_source_store import AudioSourceStore
from wled_controller.storage.profile_store import ProfileStore from wled_controller.storage.profile_store import ProfileStore
from wled_controller.core.profiles.profile_engine import ProfileEngine from wled_controller.core.profiles.profile_engine import ProfileEngine
@@ -19,6 +20,7 @@ _pattern_template_store: PatternTemplateStore | None = None
_picture_source_store: PictureSourceStore | None = None _picture_source_store: PictureSourceStore | None = None
_picture_target_store: PictureTargetStore | None = None _picture_target_store: PictureTargetStore | None = None
_color_strip_store: ColorStripStore | None = None _color_strip_store: ColorStripStore | None = None
_audio_source_store: AudioSourceStore | None = None
_processor_manager: ProcessorManager | None = None _processor_manager: ProcessorManager | None = None
_profile_store: ProfileStore | None = None _profile_store: ProfileStore | None = None
_profile_engine: ProfileEngine | None = None _profile_engine: ProfileEngine | None = None
@@ -73,6 +75,13 @@ def get_color_strip_store() -> ColorStripStore:
return _color_strip_store 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: def get_processor_manager() -> ProcessorManager:
"""Get processor manager dependency.""" """Get processor manager dependency."""
if _processor_manager is None: if _processor_manager is None:
@@ -103,13 +112,14 @@ def init_dependencies(
picture_source_store: PictureSourceStore | None = None, picture_source_store: PictureSourceStore | None = None,
picture_target_store: PictureTargetStore | None = None, picture_target_store: PictureTargetStore | None = None,
color_strip_store: ColorStripStore | None = None, color_strip_store: ColorStripStore | None = None,
audio_source_store: AudioSourceStore | None = None,
profile_store: ProfileStore | None = None, profile_store: ProfileStore | None = None,
profile_engine: ProfileEngine | None = None, profile_engine: ProfileEngine | None = None,
): ):
"""Initialize global dependencies.""" """Initialize global dependencies."""
global _device_store, _template_store, _processor_manager global _device_store, _template_store, _processor_manager
global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_store global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_store
global _color_strip_store, _profile_store, _profile_engine global _color_strip_store, _audio_source_store, _profile_store, _profile_engine
_device_store = device_store _device_store = device_store
_template_store = template_store _template_store = template_store
_processor_manager = processor_manager _processor_manager = processor_manager
@@ -118,5 +128,6 @@ def init_dependencies(
_picture_source_store = picture_source_store _picture_source_store = picture_source_store
_picture_target_store = picture_target_store _picture_target_store = picture_target_store
_color_strip_store = color_strip_store _color_strip_store = color_strip_store
_audio_source_store = audio_source_store
_profile_store = profile_store _profile_store = profile_store
_profile_engine = profile_engine _profile_engine = profile_engine

View File

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

View File

@@ -80,10 +80,9 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe
frame_interpolation=getattr(source, "frame_interpolation", None), frame_interpolation=getattr(source, "frame_interpolation", None),
animation=getattr(source, "animation", None), animation=getattr(source, "animation", None),
layers=getattr(source, "layers", None), layers=getattr(source, "layers", None),
zones=getattr(source, "zones", None),
visualization_mode=getattr(source, "visualization_mode", None), visualization_mode=getattr(source, "visualization_mode", None),
audio_device_index=getattr(source, "audio_device_index", None), audio_source_id=getattr(source, "audio_source_id", None),
audio_loopback=getattr(source, "audio_loopback", None),
audio_channel=getattr(source, "audio_channel", None),
sensitivity=getattr(source, "sensitivity", None), sensitivity=getattr(source, "sensitivity", None),
color_peak=getattr(source, "color_peak", None), color_peak=getattr(source, "color_peak", None),
overlay_active=overlay_active, 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 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( source = store.create_source(
name=data.name, name=data.name,
source_type=data.source_type, source_type=data.source_type,
@@ -162,10 +163,9 @@ async def create_color_strip_source(
scale=data.scale, scale=data.scale,
mirror=data.mirror, mirror=data.mirror,
layers=layers, layers=layers,
zones=zones,
visualization_mode=data.visualization_mode, visualization_mode=data.visualization_mode,
audio_device_index=data.audio_device_index, audio_source_id=data.audio_source_id,
audio_loopback=data.audio_loopback,
audio_channel=data.audio_channel,
sensitivity=data.sensitivity, sensitivity=data.sensitivity,
color_peak=data.color_peak, 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 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 = store.update_source(
source_id=source_id, source_id=source_id,
name=data.name, name=data.name,
@@ -236,10 +238,9 @@ async def update_color_strip_source(
scale=data.scale, scale=data.scale,
mirror=data.mirror, mirror=data.mirror,
layers=layers, layers=layers,
zones=zones,
visualization_mode=data.visualization_mode, visualization_mode=data.visualization_mode,
audio_device_index=data.audio_device_index, audio_source_id=data.audio_source_id,
audio_loopback=data.audio_loopback,
audio_channel=data.audio_channel,
sensitivity=data.sensitivity, sensitivity=data.sensitivity,
color_peak=data.color_peak, 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}. " detail=f"Color strip source is used as a layer in composite source(s): {names}. "
"Remove it from the composite(s) first.", "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) store.delete_source(source_id)
except HTTPException: except HTTPException:
raise raise

View File

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

View File

@@ -36,11 +36,20 @@ class CompositeLayer(BaseModel):
enabled: bool = Field(default=True, description="Whether this layer is active") 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): class ColorStripSourceCreate(BaseModel):
"""Request to create a color strip source.""" """Request to create a color strip source."""
name: str = Field(description="Source name", min_length=1, max_length=100) 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-type fields
picture_source_id: str = Field(default="", description="Picture source ID (for picture type)") 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) 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)") mirror: Optional[bool] = Field(None, description="Mirror/bounce mode (meteor)")
# composite-type fields # composite-type fields
layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type") 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 # audio-type fields
visualization_mode: Optional[str] = Field(None, description="Audio visualization: spectrum|beat_pulse|vu_meter") 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_source_id: Optional[str] = Field(None, description="Mono audio source ID (for audio type)")
audio_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback), False for mic/line-in")
sensitivity: Optional[float] = Field(None, description="Audio sensitivity/gain 0.1-5.0", ge=0.1, le=5.0) 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]") 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 # shared
led_count: int = Field(default=0, description="Total LED count (0 = auto from calibration / device)", ge=0) 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) 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") mirror: Optional[bool] = Field(None, description="Mirror/bounce mode")
# composite-type fields # composite-type fields
layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type") 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 # audio-type fields
visualization_mode: Optional[str] = Field(None, description="Audio visualization: spectrum|beat_pulse|vu_meter") 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_source_id: Optional[str] = Field(None, description="Mono audio source ID (for audio type)")
audio_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback), False for mic/line-in")
sensitivity: Optional[float] = Field(None, description="Audio sensitivity/gain 0.1-5.0", ge=0.1, le=5.0) 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]") 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 # shared
led_count: Optional[int] = Field(None, description="Total LED count (0 = auto from calibration / device)", ge=0) 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) 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") mirror: Optional[bool] = Field(None, description="Mirror/bounce mode")
# composite-type fields # composite-type fields
layers: Optional[List[dict]] = Field(None, description="Layers for composite type") 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 # audio-type fields
visualization_mode: Optional[str] = Field(None, description="Audio visualization mode") visualization_mode: Optional[str] = Field(None, description="Audio visualization mode")
audio_device_index: Optional[int] = Field(None, description="Audio device index") audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
audio_loopback: Optional[bool] = Field(None, description="WASAPI loopback mode")
sensitivity: Optional[float] = Field(None, description="Audio sensitivity") sensitivity: Optional[float] = Field(None, description="Audio sensitivity")
color_peak: Optional[List[int]] = Field(None, description="Peak color [R,G,B]") 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 # shared
led_count: int = Field(0, description="Total LED count (0 = auto from calibration / device)") led_count: int = Field(0, description="Total LED count (0 = auto from calibration / device)")
description: Optional[str] = Field(None, description="Description") description: Optional[str] = Field(None, description="Description")

View File

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

View File

@@ -35,8 +35,9 @@ class AudioColorStripStream(ColorStripStream):
thread, double-buffered output, configure() for auto-sizing. 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_capture_manager = audio_capture_manager
self._audio_source_store = audio_source_store
self._audio_stream = None # acquired on start self._audio_stream = None # acquired on start
self._colors_lock = threading.Lock() self._colors_lock = threading.Lock()
@@ -55,8 +56,6 @@ class AudioColorStripStream(ColorStripStream):
def _update_from_source(self, source) -> None: def _update_from_source(self, source) -> None:
self._visualization_mode = getattr(source, "visualization_mode", "spectrum") 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._sensitivity = float(getattr(source, "sensitivity", 1.0))
self._smoothing = float(getattr(source, "smoothing", 0.3)) self._smoothing = float(getattr(source, "smoothing", 0.3))
self._palette_name = getattr(source, "palette", "rainbow") self._palette_name = getattr(source, "palette", "rainbow")
@@ -68,7 +67,26 @@ class AudioColorStripStream(ColorStripStream):
self._auto_size = not source.led_count 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._led_count = source.led_count if source.led_count and source.led_count > 0 else 1
self._mirror = bool(getattr(source, "mirror", False)) 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: with self._colors_lock:
self._colors: Optional[np.ndarray] = None self._colors: Optional[np.ndarray] = None

View File

@@ -56,16 +56,18 @@ class ColorStripStreamManager:
keyed by ``{css_id}:{consumer_id}``. 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: Args:
color_strip_store: ColorStripStore for resolving source configs color_strip_store: ColorStripStore for resolving source configs
live_stream_manager: LiveStreamManager for acquiring picture streams live_stream_manager: LiveStreamManager for acquiring picture streams
audio_capture_manager: AudioCaptureManager for audio-reactive sources 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._color_strip_store = color_strip_store
self._live_stream_manager = live_stream_manager self._live_stream_manager = live_stream_manager
self._audio_capture_manager = audio_capture_manager self._audio_capture_manager = audio_capture_manager
self._audio_source_store = audio_source_store
self._streams: Dict[str, _ColorStripEntry] = {} self._streams: Dict[str, _ColorStripEntry] = {}
def _resolve_key(self, css_id: str, consumer_id: str) -> str: def _resolve_key(self, css_id: str, consumer_id: str) -> str:
@@ -104,10 +106,13 @@ class ColorStripStreamManager:
if not source.sharable: if not source.sharable:
if source.source_type == "audio": if source.source_type == "audio":
from wled_controller.core.processing.audio_stream import AudioColorStripStream 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": elif source.source_type == "composite":
from wled_controller.core.processing.composite_stream import CompositeColorStripStream from wled_controller.core.processing.composite_stream import CompositeColorStripStream
css_stream = CompositeColorStripStream(source, self) 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: else:
stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type) stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type)
if not stream_cls: if not stream_cls:

View File

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

View File

@@ -64,7 +64,7 @@ class ProcessorManager:
Targets are registered for processing via polymorphic TargetProcessor subclasses. Targets are registered for processing via polymorphic TargetProcessor subclasses.
""" """
def __init__(self, picture_source_store=None, capture_template_store=None, pp_template_store=None, pattern_template_store=None, device_store=None, color_strip_store=None): 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.""" """Initialize processor manager."""
self._devices: Dict[str, DeviceState] = {} self._devices: Dict[str, DeviceState] = {}
self._processors: Dict[str, TargetProcessor] = {} self._processors: Dict[str, TargetProcessor] = {}
@@ -77,6 +77,7 @@ class ProcessorManager:
self._pattern_template_store = pattern_template_store self._pattern_template_store = pattern_template_store
self._device_store = device_store self._device_store = device_store
self._color_strip_store = color_strip_store self._color_strip_store = color_strip_store
self._audio_source_store = audio_source_store
self._live_stream_manager = LiveStreamManager( self._live_stream_manager = LiveStreamManager(
picture_source_store, capture_template_store, pp_template_store picture_source_store, capture_template_store, pp_template_store
) )
@@ -85,6 +86,7 @@ class ProcessorManager:
color_strip_store=color_strip_store, color_strip_store=color_strip_store,
live_stream_manager=self._live_stream_manager, live_stream_manager=self._live_stream_manager,
audio_capture_manager=self._audio_capture_manager, audio_capture_manager=self._audio_capture_manager,
audio_source_store=audio_source_store,
) )
self._overlay_manager = OverlayManager() self._overlay_manager = OverlayManager()
self._event_queues: List[asyncio.Queue] = [] self._event_queues: List[asyncio.Queue] = []

View File

@@ -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_source_store import PictureSourceStore
from wled_controller.storage.picture_target_store import PictureTargetStore from wled_controller.storage.picture_target_store import PictureTargetStore
from wled_controller.storage.color_strip_store import ColorStripStore from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.audio_source_store import AudioSourceStore
from wled_controller.storage.profile_store import ProfileStore from wled_controller.storage.profile_store import ProfileStore
from wled_controller.core.profiles.profile_engine import ProfileEngine from wled_controller.core.profiles.profile_engine import ProfileEngine
from wled_controller.utils import setup_logging, get_logger from wled_controller.utils import setup_logging, get_logger
@@ -42,8 +43,12 @@ picture_source_store = PictureSourceStore(config.storage.picture_sources_file)
picture_target_store = PictureTargetStore(config.storage.picture_targets_file) picture_target_store = PictureTargetStore(config.storage.picture_targets_file)
pattern_template_store = PatternTemplateStore(config.storage.pattern_templates_file) pattern_template_store = PatternTemplateStore(config.storage.pattern_templates_file)
color_strip_store = ColorStripStore(config.storage.color_strip_sources_file) color_strip_store = ColorStripStore(config.storage.color_strip_sources_file)
audio_source_store = AudioSourceStore(config.storage.audio_sources_file)
profile_store = ProfileStore(config.storage.profiles_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( processor_manager = ProcessorManager(
picture_source_store=picture_source_store, picture_source_store=picture_source_store,
capture_template_store=template_store, capture_template_store=template_store,
@@ -51,6 +56,7 @@ processor_manager = ProcessorManager(
pattern_template_store=pattern_template_store, pattern_template_store=pattern_template_store,
device_store=device_store, device_store=device_store,
color_strip_store=color_strip_store, color_strip_store=color_strip_store,
audio_source_store=audio_source_store,
) )
@@ -94,6 +100,7 @@ async def lifespan(app: FastAPI):
picture_source_store=picture_source_store, picture_source_store=picture_source_store,
picture_target_store=picture_target_store, picture_target_store=picture_target_store,
color_strip_store=color_strip_store, color_strip_store=color_strip_store,
audio_source_store=audio_source_store,
profile_store=profile_store, profile_store=profile_store,
profile_engine=profile_engine, profile_engine=profile_engine,
) )

View File

@@ -2,8 +2,8 @@ header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 20px 0 10px; padding: 8px 0 6px;
margin-bottom: 10px; margin-bottom: 6px;
position: relative; position: relative;
z-index: 100; z-index: 100;
} }
@@ -11,11 +11,11 @@ header {
.header-title { .header-title {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 8px;
} }
h1 { h1 {
font-size: 2rem; font-size: 1.25rem;
color: var(--primary-text-color); color: var(--primary-text-color);
} }
@@ -28,7 +28,7 @@ h2 {
.server-info { .server-info {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 15px; gap: 8px;
} }
.header-link { .header-link {
@@ -57,7 +57,7 @@ h2 {
} }
.status-badge { .status-badge {
font-size: 1.5rem; font-size: 1rem;
animation: pulse 2s infinite; animation: pulse 2s infinite;
} }
@@ -156,12 +156,12 @@ h2 {
.theme-toggle { .theme-toggle {
background: var(--card-bg); background: var(--card-bg);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
padding: 6px 12px; padding: 4px 8px;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 1.2rem; font-size: 1rem;
transition: transform 0.2s; transition: transform 0.2s;
margin-left: 10px; margin-left: 0;
} }
.theme-toggle:hover { .theme-toggle:hover {
@@ -170,14 +170,14 @@ h2 {
/* Footer */ /* Footer */
.app-footer { .app-footer {
margin-top: 20px; margin-top: 12px;
padding: 15px 0; padding: 6px 0;
text-align: center; text-align: center;
} }
.footer-content { .footer-content {
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.9rem; font-size: 0.75rem;
} }
.footer-content p { .footer-content p {
@@ -203,7 +203,7 @@ h2 {
@media (max-width: 768px) { @media (max-width: 768px) {
header { header {
flex-direction: column; flex-direction: column;
gap: 15px; gap: 8px;
text-align: center; text-align: center;
} }
} }

View File

@@ -85,7 +85,7 @@ import {
import { import {
loadTargetsTab, loadTargets, switchTargetSubTab, loadTargetsTab, loadTargets, switchTargetSubTab,
showTargetEditor, closeTargetEditorModal, forceCloseTargetEditorModal, saveTargetEditor, showTargetEditor, closeTargetEditorModal, forceCloseTargetEditorModal, saveTargetEditor,
addTargetSegment, removeTargetSegment, addTargetSegment, removeTargetSegment, syncSegmentsMappedMode,
startTargetProcessing, stopTargetProcessing, startTargetProcessing, stopTargetProcessing,
startTargetOverlay, stopTargetOverlay, deleteTarget, startTargetOverlay, stopTargetOverlay, deleteTarget,
cloneTarget, cloneTarget,
@@ -97,11 +97,18 @@ import {
onCSSTypeChange, onEffectTypeChange, onAnimationTypeChange, updateEffectPreview, onCSSTypeChange, onEffectTypeChange, onAnimationTypeChange, updateEffectPreview,
colorCycleAddColor, colorCycleRemoveColor, colorCycleAddColor, colorCycleRemoveColor,
compositeAddLayer, compositeRemoveLayer, compositeAddLayer, compositeRemoveLayer,
mappedAddZone, mappedRemoveZone,
onAudioVizChange, onAudioVizChange,
applyGradientPreset, applyGradientPreset,
cloneColorStrip, cloneColorStrip,
} from './features/color-strips.js'; } from './features/color-strips.js';
// Layer 5: audio sources
import {
showAudioSourceModal, closeAudioSourceModal, saveAudioSource,
editAudioSource, deleteAudioSource, onAudioSourceTypeChange,
} from './features/audio-sources.js';
// Layer 5: calibration // Layer 5: calibration
import { import {
showCalibration, closeCalibrationModal, forceCloseCalibrationModal, saveCalibration, showCalibration, closeCalibrationModal, forceCloseCalibrationModal, saveCalibration,
@@ -278,6 +285,7 @@ Object.assign(window, {
saveTargetEditor, saveTargetEditor,
addTargetSegment, addTargetSegment,
removeTargetSegment, removeTargetSegment,
syncSegmentsMappedMode,
startTargetProcessing, startTargetProcessing,
stopTargetProcessing, stopTargetProcessing,
startTargetOverlay, startTargetOverlay,
@@ -299,10 +307,20 @@ Object.assign(window, {
colorCycleRemoveColor, colorCycleRemoveColor,
compositeAddLayer, compositeAddLayer,
compositeRemoveLayer, compositeRemoveLayer,
mappedAddZone,
mappedRemoveZone,
onAudioVizChange, onAudioVizChange,
applyGradientPreset, applyGradientPreset,
cloneColorStrip, cloneColorStrip,
// audio sources
showAudioSourceModal,
closeAudioSourceModal,
saveAudioSource,
editAudioSource,
deleteAudioSource,
onAudioSourceTypeChange,
// calibration // calibration
showCalibration, showCalibration,
closeCalibrationModal, closeCalibrationModal,

View File

@@ -165,6 +165,10 @@ export const PATTERN_RECT_BORDERS = [
'#4CAF50', '#2196F3', '#FF9800', '#9C27B0', '#00BCD4', '#F44336', '#FFEB3B', '#795548', '#4CAF50', '#2196F3', '#FF9800', '#9C27B0', '#00BCD4', '#F44336', '#FFEB3B', '#795548',
]; ];
// Audio sources
export let _cachedAudioSources = [];
export function set_cachedAudioSources(v) { _cachedAudioSources = v; }
// Profiles // Profiles
export let _profilesCache = null; export let _profilesCache = null;
export function set_profilesCache(v) { _profilesCache = v; } export function set_profilesCache(v) { _profilesCache = v; }

View File

@@ -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 `<option value="${val}">${escapeHtml(label)}</option>`;
}).join('');
if (devices.length === 0) {
select.innerHTML = '<option value="-1:1">Default</option>';
}
} catch {
select.innerHTML = '<option value="-1:1">Default</option>';
}
}
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 =>
`<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
}

View File

@@ -39,8 +39,9 @@ class CSSEditorModal extends Modal {
effect_scale: document.getElementById('css-editor-effect-scale').value, effect_scale: document.getElementById('css-editor-effect-scale').value,
effect_mirror: document.getElementById('css-editor-effect-mirror').checked, effect_mirror: document.getElementById('css-editor-effect-mirror').checked,
composite_layers: JSON.stringify(_compositeLayers), composite_layers: JSON.stringify(_compositeLayers),
mapped_zones: JSON.stringify(_mappedZones),
audio_viz: document.getElementById('css-editor-audio-viz').value, 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_sensitivity: document.getElementById('css-editor-audio-sensitivity').value,
audio_smoothing: document.getElementById('css-editor-audio-smoothing').value, audio_smoothing: document.getElementById('css-editor-audio-smoothing').value,
audio_palette: document.getElementById('css-editor-audio-palette').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-gradient-section').style.display = type === 'gradient' ? '' : 'none';
document.getElementById('css-editor-effect-section').style.display = type === 'effect' ? '' : '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-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'; document.getElementById('css-editor-audio-section').style.display = type === 'audio' ? '' : 'none';
if (type === 'effect') onEffectTypeChange(); if (type === 'effect') onEffectTypeChange();
@@ -97,14 +99,16 @@ export function onCSSTypeChange() {
} }
_syncAnimationSpeedState(); _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 = document.getElementById('css-editor-led-count-group').style.display =
(type === 'composite' || type === 'audio') ? 'none' : ''; (type === 'composite' || type === 'mapped' || type === 'audio') ? 'none' : '';
if (type === 'audio') { if (type === 'audio') {
_loadAudioDevices(); _loadAudioSources();
} else if (type === 'composite') { } else if (type === 'composite') {
_compositeRenderList(); _compositeRenderList();
} else if (type === 'mapped') {
_mappedRenderList();
} else if (type === 'gradient') { } else if (type === 'gradient') {
requestAnimationFrame(() => gradientRenderAll()); requestAnimationFrame(() => gradientRenderAll());
} }
@@ -391,6 +395,107 @@ function _loadCompositeState(css) {
_compositeRenderList(); _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 =>
`<option value="${s.id}"${zone.source_id === s.id ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
return `
<div class="segment-row">
<div class="segment-row-header">
<span class="segment-index-label">#${i + 1}</span>
<button type="button" class="btn-icon-inline btn-danger-text"
onclick="mappedRemoveZone(${i})" title="${t('common.delete')}">&times;</button>
</div>
<div class="segment-row-fields">
<select class="mapped-zone-source" data-idx="${i}">${srcOptions}</select>
<div class="segment-range-fields">
<label>${t('color_strip.mapped.zone_start')}</label>
<input type="number" class="mapped-zone-start" data-idx="${i}"
min="0" value="${zone.start}" placeholder="0">
<label>${t('color_strip.mapped.zone_end')}</label>
<input type="number" class="mapped-zone-end" data-idx="${i}"
min="0" value="${zone.end}" placeholder="0 = auto">
</div>
<label class="segment-reverse-label">
<input type="checkbox" class="mapped-zone-reverse" data-idx="${i}"${zone.reverse ? ' checked' : ''}>
<span>${t('color_strip.mapped.zone_reverse')}</span>
</label>
</div>
</div>
`;
}).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 ──────────────────────────────── */ /* ── Audio visualization helpers ──────────────────────────────── */
export function onAudioVizChange() { export function onAudioVizChange() {
@@ -405,24 +510,22 @@ export function onAudioVizChange() {
document.getElementById('css-editor-audio-mirror-group').style.display = viz === 'spectrum' ? '' : 'none'; document.getElementById('css-editor-audio-mirror-group').style.display = viz === 'spectrum' ? '' : 'none';
} }
async function _loadAudioDevices() { async function _loadAudioSources() {
const select = document.getElementById('css-editor-audio-device'); const select = document.getElementById('css-editor-audio-source');
if (!select) return; if (!select) return;
try { try {
const resp = await fetchWithAuth('/audio-devices'); const resp = await fetchWithAuth('/audio-sources?source_type=mono');
if (!resp.ok) throw new Error('fetch failed'); if (!resp.ok) throw new Error('fetch failed');
const data = await resp.json(); const data = await resp.json();
const devices = data.devices || []; const sources = data.sources || [];
select.innerHTML = devices.map(d => { select.innerHTML = sources.map(s =>
const label = d.is_loopback ? `🔊 ${d.name}` : `🎤 ${d.name}`; `<option value="${s.id}">${escapeHtml(s.name)}</option>`
const val = `${d.index}:${d.is_loopback ? '1' : '0'}`; ).join('');
return `<option value="${val}">${escapeHtml(label)}</option>`; if (sources.length === 0) {
}).join(''); select.innerHTML = '';
if (devices.length === 0) {
select.innerHTML = '<option value="-1:1">Default</option>';
} }
} catch { } catch {
select.innerHTML = '<option value="-1:1">Default</option>'; select.innerHTML = '';
} }
} }
@@ -438,21 +541,15 @@ function _loadAudioState(css) {
document.getElementById('css-editor-audio-smoothing').value = smoothing; 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-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-palette').value = css.palette || 'rainbow';
document.getElementById('css-editor-audio-color').value = rgbArrayToHex(css.color || [0, 255, 0]); 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-color-peak').value = rgbArrayToHex(css.color_peak || [255, 0, 0]);
document.getElementById('css-editor-audio-mirror').checked = css.mirror || false; document.getElementById('css-editor-audio-mirror').checked = css.mirror || false;
// Set audio device selector to match stored values // Set audio source selector
const deviceIdx = css.audio_device_index ?? -1; const select = document.getElementById('css-editor-audio-source');
const loopback = css.audio_loopback !== false ? '1' : '0'; if (select && css.audio_source_id) {
const deviceVal = `${deviceIdx}:${loopback}`; select.value = css.audio_source_id;
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;
} }
} }
@@ -462,7 +559,6 @@ function _resetAudioState() {
document.getElementById('css-editor-audio-sensitivity-val').textContent = '1.0'; 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').value = 0.3;
document.getElementById('css-editor-audio-smoothing-val').textContent = '0.30'; 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-palette').value = 'rainbow';
document.getElementById('css-editor-audio-color').value = '#00ff00'; document.getElementById('css-editor-audio-color').value = '#00ff00';
document.getElementById('css-editor-audio-color-peak').value = '#ff0000'; 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 isColorCycle = source.source_type === 'color_cycle';
const isEffect = source.source_type === 'effect'; const isEffect = source.source_type === 'effect';
const isComposite = source.source_type === 'composite'; const isComposite = source.source_type === 'composite';
const isMapped = source.source_type === 'mapped';
const isAudio = source.source_type === 'audio'; const isAudio = source.source_type === 'audio';
const anim = (isStatic || isGradient) && source.animation && source.animation.enabled ? source.animation : null; const anim = (isStatic || isGradient) && source.animation && source.animation.enabled ? source.animation : null;
@@ -543,15 +640,20 @@ export function createColorStripCard(source, pictureSourceMap) {
<span class="stream-card-prop">🔗 ${enabledCount}/${layerCount} ${t('color_strip.composite.layers_count')}</span> <span class="stream-card-prop">🔗 ${enabledCount}/${layerCount} ${t('color_strip.composite.layers_count')}</span>
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${source.led_count}</span>` : ''} ${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${source.led_count}</span>` : ''}
`; `;
} else if (isMapped) {
const zoneCount = (source.zones || []).length;
propsHtml = `
<span class="stream-card-prop">📍 ${zoneCount} ${t('color_strip.mapped.zones_count')}</span>
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${source.led_count}</span>` : ''}
`;
} else if (isAudio) { } else if (isAudio) {
const vizLabel = t('color_strip.audio.viz.' + (source.visualization_mode || 'spectrum')) || source.visualization_mode || 'spectrum'; const vizLabel = t('color_strip.audio.viz.' + (source.visualization_mode || 'spectrum')) || source.visualization_mode || 'spectrum';
const sensitivityVal = (source.sensitivity || 1.0).toFixed(1); const sensitivityVal = (source.sensitivity || 1.0).toFixed(1);
const ch = source.audio_channel || 'mono'; const srcLabel = source.audio_source_id || '';
const chBadge = ch !== 'mono' ? `<span class="stream-card-prop" title="${t('color_strip.audio.channel')}">${ch === 'left' ? 'L' : 'R'}</span>` : '';
propsHtml = ` propsHtml = `
<span class="stream-card-prop">🎵 ${escapeHtml(vizLabel)}</span> <span class="stream-card-prop">🎵 ${escapeHtml(vizLabel)}</span>
<span class="stream-card-prop" title="${t('color_strip.audio.sensitivity')}">📶 ${sensitivityVal}</span> <span class="stream-card-prop" title="${t('color_strip.audio.sensitivity')}">📶 ${sensitivityVal}</span>
${chBadge} ${source.audio_source_id ? `<span class="stream-card-prop" title="${t('color_strip.audio.source')}">🔊</span>` : ''}
${source.mirror ? `<span class="stream-card-prop">🪞</span>` : ''} ${source.mirror ? `<span class="stream-card-prop">🪞</span>` : ''}
`; `;
} else { } else {
@@ -567,8 +669,8 @@ export function createColorStripCard(source, pictureSourceMap) {
`; `;
} }
const icon = isStatic ? '🎨' : isColorCycle ? '🔄' : isGradient ? '🌈' : isEffect ? '⚡' : isComposite ? '🔗' : isAudio ? '🎵' : '🎞️'; const icon = isStatic ? '🎨' : isColorCycle ? '🔄' : isGradient ? '🌈' : isEffect ? '⚡' : isComposite ? '🔗' : isMapped ? '📍' : isAudio ? '🎵' : '🎞️';
const calibrationBtn = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isAudio) const calibrationBtn = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isMapped && !isAudio)
? `<button class="btn btn-icon btn-secondary" onclick="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">📐</button>` ? `<button class="btn btn-icon btn-secondary" onclick="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">📐</button>`
: ''; : '';
@@ -605,6 +707,9 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
_compositeAvailableSources = allCssSources.filter(s => _compositeAvailableSources = allCssSources.filter(s =>
s.source_type !== 'composite' && (!cssId || s.id !== cssId) 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'); const sourceSelect = document.getElementById('css-editor-picture-source');
sourceSelect.innerHTML = ''; 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-scale-val').textContent = parseFloat(css.scale ?? 1.0).toFixed(1);
document.getElementById('css-editor-effect-mirror').checked = css.mirror || false; document.getElementById('css-editor-effect-mirror').checked = css.mirror || false;
} else if (sourceType === 'audio') { } else if (sourceType === 'audio') {
await _loadAudioDevices(); await _loadAudioSources();
_loadAudioState(css); _loadAudioState(css);
} else if (sourceType === 'composite') { } else if (sourceType === 'composite') {
_loadCompositeState(css); _loadCompositeState(css);
} else if (sourceType === 'mapped') {
_loadMappedState(css);
} else { } else {
sourceSelect.value = css.picture_source_id || ''; 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-id').value = css.id;
document.getElementById('css-editor-name').value = css.name; 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') { if (css.source_type === 'composite') {
_compositeAvailableSources = allCssSources.filter(s => _compositeAvailableSources = allCssSources.filter(s =>
s.source_type !== 'composite' && s.id !== css.id 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); 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-scale-val').textContent = '1.0';
document.getElementById('css-editor-effect-mirror').checked = false; document.getElementById('css-editor-effect-mirror').checked = false;
_loadCompositeState(null); _loadCompositeState(null);
_resetMappedState();
_resetAudioState(); _resetAudioState();
document.getElementById('css-editor-title').textContent = t('color_strip.add'); document.getElementById('css-editor-title').textContent = t('color_strip.add');
document.getElementById('css-editor-gradient-preset').value = ''; document.getElementById('css-editor-gradient-preset').value = '';
@@ -820,14 +932,10 @@ export async function saveCSSEditor() {
} }
if (!cssId) payload.source_type = 'effect'; if (!cssId) payload.source_type = 'effect';
} else if (sourceType === 'audio') { } else if (sourceType === 'audio') {
const deviceVal = document.getElementById('css-editor-audio-device').value || '-1:1';
const [devIdx, devLoop] = deviceVal.split(':');
payload = { payload = {
name, name,
visualization_mode: document.getElementById('css-editor-audio-viz').value, visualization_mode: document.getElementById('css-editor-audio-viz').value,
audio_device_index: parseInt(devIdx) || -1, audio_source_id: document.getElementById('css-editor-audio-source').value || null,
audio_loopback: devLoop !== '0',
audio_channel: document.getElementById('css-editor-audio-channel').value,
sensitivity: parseFloat(document.getElementById('css-editor-audio-sensitivity').value), sensitivity: parseFloat(document.getElementById('css-editor-audio-sensitivity').value),
smoothing: parseFloat(document.getElementById('css-editor-audio-smoothing').value), smoothing: parseFloat(document.getElementById('css-editor-audio-smoothing').value),
palette: document.getElementById('css-editor-audio-palette').value, palette: document.getElementById('css-editor-audio-palette').value,
@@ -852,6 +960,15 @@ export async function saveCSSEditor() {
layers, layers,
}; };
if (!cssId) payload.source_type = 'composite'; 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 { } else {
payload = { payload = {
name, name,

View File

@@ -18,6 +18,7 @@ import {
_currentTestStreamId, set_currentTestStreamId, _currentTestStreamId, set_currentTestStreamId,
_currentTestPPTemplateId, set_currentTestPPTemplateId, _currentTestPPTemplateId, set_currentTestPPTemplateId,
_lastValidatedImageSource, set_lastValidatedImageSource, _lastValidatedImageSource, set_lastValidatedImageSource,
_cachedAudioSources, set_cachedAudioSources,
apiKey, apiKey,
} from '../core/state.js'; } from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
@@ -436,11 +437,12 @@ export async function deleteTemplate(templateId) {
export async function loadPictureSources() { export async function loadPictureSources() {
try { 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), _availableFilters.length === 0 ? fetchWithAuth('/filters') : Promise.resolve(null),
fetchWithAuth('/postprocessing-templates'), fetchWithAuth('/postprocessing-templates'),
fetchWithAuth('/capture-templates'), fetchWithAuth('/capture-templates'),
fetchWithAuth('/picture-sources') fetchWithAuth('/picture-sources'),
fetchWithAuth('/audio-sources'),
]); ]);
if (filtersResp && filtersResp.ok) { if (filtersResp && filtersResp.ok) {
@@ -455,6 +457,10 @@ export async function loadPictureSources() {
const cd = await captResp.json(); const cd = await captResp.json();
set_cachedCaptureTemplates(cd.templates || []); 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}`); if (!streamsResp.ok) throw new Error(`Failed to load streams: ${streamsResp.status}`);
const data = await streamsResp.json(); const data = await streamsResp.json();
set_cachedStreams(data.streams || []); set_cachedStreams(data.streams || []);
@@ -596,21 +602,60 @@ function renderPictureSourcesList(streams) {
const processedStreams = streams.filter(s => s.stream_type === 'processed'); const processedStreams = streams.filter(s => s.stream_type === 'processed');
const staticImageStreams = streams.filter(s => s.stream_type === 'static_image'); 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 addStreamCard = (type) => `
<div class="template-card add-template-card" onclick="showAddStreamModal('${type}')"> <div class="template-card add-template-card" onclick="showAddStreamModal('${type}')">
<div class="add-template-icon">+</div> <div class="add-template-icon">+</div>
</div>`; </div>`;
const tabs = [ const tabs = [
{ key: 'raw', icon: '🖥️', titleKey: 'streams.group.raw', streams: rawStreams }, { key: 'raw', icon: '🖥️', titleKey: 'streams.group.raw', count: rawStreams.length },
{ key: 'static_image', icon: '🖼️', titleKey: 'streams.group.static_image', streams: staticImageStreams }, { key: 'static_image', icon: '🖼️', titleKey: 'streams.group.static_image', count: staticImageStreams.length },
{ key: 'processed', icon: '🎨', titleKey: 'streams.group.processed', streams: processedStreams }, { key: 'processed', icon: '🎨', titleKey: 'streams.group.processed', count: processedStreams.length },
{ key: 'audio', icon: '🔊', titleKey: 'streams.group.audio', count: _cachedAudioSources.length },
]; ];
const tabBar = `<div class="stream-tab-bar">${tabs.map(tab => const tabBar = `<div class="stream-tab-bar">${tabs.map(tab =>
`<button class="stream-tab-btn${tab.key === activeTab ? ' active' : ''}" data-stream-tab="${tab.key}" onclick="switchStreamTab('${tab.key}')">${tab.icon} ${t(tab.titleKey)} <span class="stream-tab-count">${tab.streams.length}</span></button>` `<button class="stream-tab-btn${tab.key === activeTab ? ' active' : ''}" data-stream-tab="${tab.key}" onclick="switchStreamTab('${tab.key}')">${tab.icon} ${t(tab.titleKey)} <span class="stream-tab-count">${tab.count}</span></button>`
).join('')}</div>`; ).join('')}</div>`;
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 = `
<span class="stream-card-prop" title="${escapeHtml(t('audio_source.parent'))}">${escapeHtml(parentName)}</span>
<span class="stream-card-prop" title="${escapeHtml(t('audio_source.channel'))}">${chLabel}</span>
`;
} else {
const devIdx = src.device_index ?? -1;
const loopback = src.is_loopback !== false;
const devLabel = loopback ? '🔊 Loopback' : '🎤 Input';
propsHtml = `<span class="stream-card-prop">${devLabel} #${devIdx}</span>`;
}
return `
<div class="template-card" data-id="${src.id}">
<button class="card-remove-btn" onclick="deleteAudioSource('${src.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="template-card-header">
<div class="template-name">${icon} ${escapeHtml(src.name)}</div>
</div>
<div class="stream-card-props">${propsHtml}</div>
${src.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(src.description)}</div>` : ''}
<div class="template-card-actions">
<button class="btn btn-icon btn-secondary" onclick="editAudioSource('${src.id}')" title="${t('common.edit')}">✏️</button>
</div>
</div>
`;
};
const panels = tabs.map(tab => { const panels = tabs.map(tab => {
let panelContent = ''; let panelContent = '';
@@ -619,7 +664,7 @@ function renderPictureSourcesList(streams) {
<div class="subtab-section"> <div class="subtab-section">
<h3 class="subtab-section-header">${t('streams.section.streams')}</h3> <h3 class="subtab-section-header">${t('streams.section.streams')}</h3>
<div class="templates-grid"> <div class="templates-grid">
${tab.streams.map(renderStreamCard).join('')} ${rawStreams.map(renderStreamCard).join('')}
${addStreamCard(tab.key)} ${addStreamCard(tab.key)}
</div> </div>
</div> </div>
@@ -637,7 +682,7 @@ function renderPictureSourcesList(streams) {
<div class="subtab-section"> <div class="subtab-section">
<h3 class="subtab-section-header">${t('streams.section.streams')}</h3> <h3 class="subtab-section-header">${t('streams.section.streams')}</h3>
<div class="templates-grid"> <div class="templates-grid">
${tab.streams.map(renderStreamCard).join('')} ${processedStreams.map(renderStreamCard).join('')}
${addStreamCard(tab.key)} ${addStreamCard(tab.key)}
</div> </div>
</div> </div>
@@ -650,10 +695,30 @@ function renderPictureSourcesList(streams) {
</div> </div>
</div> </div>
</div>`; </div>`;
} else if (tab.key === 'audio') {
panelContent = `
<div class="subtab-section">
<h3 class="subtab-section-header">${t('audio_source.group.multichannel')}</h3>
<div class="templates-grid">
${multichannelSources.map(renderAudioSourceCard).join('')}
<div class="template-card add-template-card" onclick="showAudioSourceModal('multichannel')">
<div class="add-template-icon">+</div>
</div>
</div>
</div>
<div class="subtab-section">
<h3 class="subtab-section-header">${t('audio_source.group.mono')}</h3>
<div class="templates-grid">
${monoSources.map(renderAudioSourceCard).join('')}
<div class="template-card add-template-card" onclick="showAudioSourceModal('mono')">
<div class="add-template-icon">+</div>
</div>
</div>
</div>`;
} else { } else {
panelContent = ` panelContent = `
<div class="templates-grid"> <div class="templates-grid">
${tab.streams.map(renderStreamCard).join('')} ${staticImageStreams.map(renderStreamCard).join('')}
${addStreamCard(tab.key)} ${addStreamCard(tab.key)}
</div>`; </div>`;
} }

View File

@@ -77,6 +77,56 @@ function _createTargetFpsChart(canvasId, history, fpsTarget, maxHwFps) {
// --- Segment editor state --- // --- Segment editor state ---
let _editorCssSources = []; // populated when editor opens 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() { function _serializeSegments() {
const rows = document.querySelectorAll('.segment-row'); const rows = document.querySelectorAll('.segment-row');
const segments = []; const segments = [];
@@ -195,7 +245,7 @@ function _renderSegmentRowInner(index, segment) {
<button type="button" class="btn-icon-inline btn-danger-text" onclick="removeTargetSegment(this)" title="${t('targets.segment.remove')}">&times;</button> <button type="button" class="btn-icon-inline btn-danger-text" onclick="removeTargetSegment(this)" title="${t('targets.segment.remove')}">&times;</button>
</div> </div>
<div class="segment-row-fields"> <div class="segment-row-fields">
<select class="segment-css-select" onchange="window._targetAutoName && window._targetAutoName()">${options}</select> <select class="segment-css-select" onchange="window._targetAutoName && window._targetAutoName(); syncSegmentsMappedMode()">${options}</select>
<div class="segment-range-fields"> <div class="segment-range-fields">
<label>${t('targets.segment.start')}</label> <label>${t('targets.segment.start')}</label>
<input type="number" class="segment-start" min="0" value="${start}" placeholder="0"> <input type="number" class="segment-start" min="0" value="${start}" placeholder="0">
@@ -293,6 +343,8 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
addTargetSegment(); addTargetSegment();
} }
syncSegmentsMappedMode();
// Auto-name generation // Auto-name generation
_targetNameManuallyEdited = !!(targetId || cloneData); _targetNameManuallyEdited = !!(targetId || cloneData);
document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; }; document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; };

View File

@@ -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.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.raw": "Screen Capture",
"streams.group.processed": "Processed", "streams.group.processed": "Processed",
"streams.group.audio": "Audio",
"streams.section.streams": "\uD83D\uDCFA Sources", "streams.section.streams": "\uD83D\uDCFA Sources",
"streams.add": "Add Source", "streams.add": "Add Source",
"streams.add.raw": "Add Screen Capture", "streams.add.raw": "Add Screen Capture",
@@ -365,6 +366,7 @@
"targets.device.hint": "Select the LED device to send data to", "targets.device.hint": "Select the LED device to send data to",
"targets.device.none": "-- Select a device --", "targets.device.none": "-- Select a device --",
"targets.segments": "Segments:", "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.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.segments.add": "+ Add Segment",
"targets.segment.start": "Start:", "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.effect.hint": "Procedural LED effects (fire, meteor, plasma, noise, aurora) generated in real time.",
"color_strip.type.composite": "Composite", "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.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": "Audio Reactive",
"color_strip.type.audio.hint": "LED colors driven by real-time audio input — system audio or microphone.", "color_strip.type.audio.hint": "LED colors driven by real-time audio input — system audio or microphone.",
"color_strip.composite.layers": "Layers:", "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.min_layers": "At least 1 layer is required",
"color_strip.composite.error.no_source": "Each layer must have a source selected", "color_strip.composite.error.no_source": "Each layer must have a source selected",
"color_strip.composite.layers_count": "layers", "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": "Visualization:",
"color_strip.audio.visualization.hint": "How audio data is rendered to LEDs.", "color_strip.audio.visualization.hint": "How audio data is rendered to LEDs.",
"color_strip.audio.viz.spectrum": "Spectrum Analyzer", "color_strip.audio.viz.spectrum": "Spectrum Analyzer",
"color_strip.audio.viz.beat_pulse": "Beat Pulse", "color_strip.audio.viz.beat_pulse": "Beat Pulse",
"color_strip.audio.viz.vu_meter": "VU Meter", "color_strip.audio.viz.vu_meter": "VU Meter",
"color_strip.audio.device": "Audio Device:", "color_strip.audio.source": "Audio Source:",
"color_strip.audio.device.hint": "Audio input source. Loopback devices capture system audio output; input devices capture microphone or line-in.", "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.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.sensitivity": "Sensitivity:", "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.sensitivity.hint": "Gain multiplier for audio levels. Higher values make LEDs react to quieter sounds.",
"color_strip.audio.smoothing": "Smoothing:", "color_strip.audio.smoothing": "Smoothing:",
@@ -723,5 +731,39 @@
"color_strip.palette.rainbow": "Rainbow", "color_strip.palette.rainbow": "Rainbow",
"color_strip.palette.aurora": "Aurora", "color_strip.palette.aurora": "Aurora",
"color_strip.palette.sunset": "Sunset", "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"
} }

View File

@@ -249,6 +249,7 @@
"streams.description": "Источники определяют конвейер захвата. Сырой источник захватывает экран с помощью шаблона захвата. Обработанный источник применяет постобработку к другому источнику. Назначайте источники устройствам.", "streams.description": "Источники определяют конвейер захвата. Сырой источник захватывает экран с помощью шаблона захвата. Обработанный источник применяет постобработку к другому источнику. Назначайте источники устройствам.",
"streams.group.raw": "Захват Экрана", "streams.group.raw": "Захват Экрана",
"streams.group.processed": "Обработанные", "streams.group.processed": "Обработанные",
"streams.group.audio": "Аудио",
"streams.section.streams": "\uD83D\uDCFA Источники", "streams.section.streams": "\uD83D\uDCFA Источники",
"streams.add": "Добавить Источник", "streams.add": "Добавить Источник",
"streams.add.raw": "Добавить Захват Экрана", "streams.add.raw": "Добавить Захват Экрана",
@@ -365,6 +366,7 @@
"targets.device.hint": "Выберите LED устройство для передачи данных", "targets.device.hint": "Выберите LED устройство для передачи данных",
"targets.device.none": "-- Выберите устройство --", "targets.device.none": "-- Выберите устройство --",
"targets.segments": "Сегменты:", "targets.segments": "Сегменты:",
"targets.color_strip_source": "Источник цветовой полосы:",
"targets.segments.hint": "Каждый сегмент отображает источник цветовой полосы на диапазон пикселей LED ленты. Промежутки между сегментами остаются чёрными. Один сегмент с Начало=0, Конец=0 авто-подгоняется под всю ленту.", "targets.segments.hint": "Каждый сегмент отображает источник цветовой полосы на диапазон пикселей LED ленты. Промежутки между сегментами остаются чёрными. Один сегмент с Начало=0, Конец=0 авто-подгоняется под всю ленту.",
"targets.segments.add": "+ Добавить сегмент", "targets.segments.add": "+ Добавить сегмент",
"targets.segment.start": "Начало:", "targets.segment.start": "Начало:",
@@ -652,6 +654,8 @@
"color_strip.type.effect.hint": "Процедурные LED-эффекты (огонь, метеор, плазма, шум, аврора), генерируемые в реальном времени.", "color_strip.type.effect.hint": "Процедурные LED-эффекты (огонь, метеор, плазма, шум, аврора), генерируемые в реальном времени.",
"color_strip.type.composite": "Композит", "color_strip.type.composite": "Композит",
"color_strip.type.composite.hint": "Наложение нескольких источников цветовой ленты как слоёв с режимами смешивания и прозрачностью.", "color_strip.type.composite.hint": "Наложение нескольких источников цветовой ленты как слоёв с режимами смешивания и прозрачностью.",
"color_strip.type.mapped": "Маппинг",
"color_strip.type.mapped.hint": "Назначает разные источники цветовой полосы на разные диапазоны LED (зоны). В отличие от композита, маппинг размещает источники рядом друг с другом.",
"color_strip.type.audio": "Аудиореактив", "color_strip.type.audio": "Аудиореактив",
"color_strip.type.audio.hint": "Цвета LED управляются аудиосигналом в реальном времени — системный звук или микрофон.", "color_strip.type.audio.hint": "Цвета LED управляются аудиосигналом в реальном времени — системный звук или микрофон.",
"color_strip.composite.layers": "Слои:", "color_strip.composite.layers": "Слои:",
@@ -668,18 +672,22 @@
"color_strip.composite.error.min_layers": "Необходим хотя бы 1 слой", "color_strip.composite.error.min_layers": "Необходим хотя бы 1 слой",
"color_strip.composite.error.no_source": "Для каждого слоя должен быть выбран источник", "color_strip.composite.error.no_source": "Для каждого слоя должен быть выбран источник",
"color_strip.composite.layers_count": "слоёв", "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": "Визуализация:",
"color_strip.audio.visualization.hint": "Способ отображения аудиоданных на LED.", "color_strip.audio.visualization.hint": "Способ отображения аудиоданных на LED.",
"color_strip.audio.viz.spectrum": "Анализатор спектра", "color_strip.audio.viz.spectrum": "Анализатор спектра",
"color_strip.audio.viz.beat_pulse": "Пульс бита", "color_strip.audio.viz.beat_pulse": "Пульс бита",
"color_strip.audio.viz.vu_meter": "VU-метр", "color_strip.audio.viz.vu_meter": "VU-метр",
"color_strip.audio.device": "Аудиоустройство:", "color_strip.audio.source": "Аудиоисточник:",
"color_strip.audio.device.hint": "Источник аудиосигнала. Устройства обратной петли захватывают системный звук; устройства ввода — микрофон или линейный вход.", "color_strip.audio.source.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.sensitivity": "Чувствительность:", "color_strip.audio.sensitivity": "Чувствительность:",
"color_strip.audio.sensitivity.hint": "Множитель усиления аудиосигнала. Более высокие значения делают LED чувствительнее к тихим звукам.", "color_strip.audio.sensitivity.hint": "Множитель усиления аудиосигнала. Более высокие значения делают LED чувствительнее к тихим звукам.",
"color_strip.audio.smoothing": "Сглаживание:", "color_strip.audio.smoothing": "Сглаживание:",
@@ -723,5 +731,39 @@
"color_strip.palette.rainbow": "Радуга", "color_strip.palette.rainbow": "Радуга",
"color_strip.palette.aurora": "Аврора", "color_strip.palette.aurora": "Аврора",
"color_strip.palette.sunset": "Закат", "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": "Введите название"
} }

View File

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

View File

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

View File

@@ -73,12 +73,11 @@ class ColorStripSource:
"scale": None, "scale": None,
"mirror": None, "mirror": None,
"layers": None, "layers": None,
"zones": None,
"visualization_mode": None, "visualization_mode": None,
"audio_device_index": None, "audio_source_id": None,
"audio_loopback": None,
"sensitivity": None, "sensitivity": None,
"color_peak": None, "color_peak": None,
"audio_channel": None,
} }
@staticmethod @staticmethod
@@ -155,6 +154,14 @@ class ColorStripSource:
led_count=data.get("led_count") or 0, 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": if source_type == "audio":
raw_color = data.get("color") raw_color = data.get("color")
color = raw_color if isinstance(raw_color, list) and len(raw_color) == 3 else [0, 255, 0] 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", id=sid, name=name, source_type="audio",
created_at=created_at, updated_at=updated_at, description=description, created_at=created_at, updated_at=updated_at, description=description,
visualization_mode=data.get("visualization_mode") or "spectrum", visualization_mode=data.get("visualization_mode") or "spectrum",
audio_device_index=int(data.get("audio_device_index", -1)), audio_source_id=data.get("audio_source_id") or "",
audio_loopback=bool(data.get("audio_loopback", True)),
audio_channel=data.get("audio_channel") or "mono",
sensitivity=float(data.get("sensitivity") or 1.0), sensitivity=float(data.get("sensitivity") or 1.0),
smoothing=float(data.get("smoothing") or 0.3), smoothing=float(data.get("smoothing") or 0.3),
palette=data.get("palette") or "rainbow", palette=data.get("palette") or "rainbow",
@@ -366,9 +371,7 @@ class AudioColorStripSource(ColorStripSource):
""" """
visualization_mode: str = "spectrum" # spectrum | beat_pulse | vu_meter visualization_mode: str = "spectrum" # spectrum | beat_pulse | vu_meter
audio_device_index: int = -1 # -1 = default input device audio_source_id: str = "" # references a MonoAudioSource
audio_loopback: bool = True # True = WASAPI loopback (system audio)
audio_channel: str = "mono" # mono | left | right
sensitivity: float = 1.0 # gain multiplier (0.15.0) sensitivity: float = 1.0 # gain multiplier (0.15.0)
smoothing: float = 0.3 # temporal smoothing (0.01.0) smoothing: float = 0.3 # temporal smoothing (0.01.0)
palette: str = "rainbow" # named color palette palette: str = "rainbow" # named color palette
@@ -380,9 +383,7 @@ class AudioColorStripSource(ColorStripSource):
def to_dict(self) -> dict: def to_dict(self) -> dict:
d = super().to_dict() d = super().to_dict()
d["visualization_mode"] = self.visualization_mode d["visualization_mode"] = self.visualization_mode
d["audio_device_index"] = self.audio_device_index d["audio_source_id"] = self.audio_source_id
d["audio_loopback"] = self.audio_loopback
d["audio_channel"] = self.audio_channel
d["sensitivity"] = self.sensitivity d["sensitivity"] = self.sensitivity
d["smoothing"] = self.smoothing d["smoothing"] = self.smoothing
d["palette"] = self.palette d["palette"] = self.palette
@@ -411,3 +412,24 @@ class CompositeColorStripSource(ColorStripSource):
d["layers"] = [dict(layer) for layer in self.layers] d["layers"] = [dict(layer) for layer in self.layers]
d["led_count"] = self.led_count d["led_count"] = self.led_count
return d 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

View File

@@ -14,6 +14,7 @@ from wled_controller.storage.color_strip_source import (
CompositeColorStripSource, CompositeColorStripSource,
EffectColorStripSource, EffectColorStripSource,
GradientColorStripSource, GradientColorStripSource,
MappedColorStripSource,
PictureColorStripSource, PictureColorStripSource,
StaticColorStripSource, StaticColorStripSource,
) )
@@ -119,10 +120,9 @@ class ColorStripStore:
scale: float = 1.0, scale: float = 1.0,
mirror: bool = False, mirror: bool = False,
layers: Optional[list] = None, layers: Optional[list] = None,
zones: Optional[list] = None,
visualization_mode: str = "spectrum", visualization_mode: str = "spectrum",
audio_device_index: int = -1, audio_source_id: str = "",
audio_loopback: bool = True,
audio_channel: str = "mono",
sensitivity: float = 1.0, sensitivity: float = 1.0,
color_peak: Optional[list] = None, color_peak: Optional[list] = None,
) -> ColorStripSource: ) -> ColorStripSource:
@@ -214,9 +214,7 @@ class ColorStripStore:
updated_at=now, updated_at=now,
description=description, description=description,
visualization_mode=visualization_mode or "spectrum", visualization_mode=visualization_mode or "spectrum",
audio_device_index=audio_device_index if audio_device_index is not None else -1, audio_source_id=audio_source_id or "",
audio_loopback=bool(audio_loopback),
audio_channel=audio_channel or "mono",
sensitivity=float(sensitivity) if sensitivity else 1.0, sensitivity=float(sensitivity) if sensitivity else 1.0,
smoothing=float(smoothing) if smoothing else 0.3, smoothing=float(smoothing) if smoothing else 0.3,
palette=palette or "rainbow", palette=palette or "rainbow",
@@ -236,6 +234,17 @@ class ColorStripStore:
layers=layers if isinstance(layers, list) else [], layers=layers if isinstance(layers, list) else [],
led_count=led_count, 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: else:
if calibration is None: if calibration is None:
calibration = CalibrationConfig(layout="clockwise", start_position="bottom_left") calibration = CalibrationConfig(layout="clockwise", start_position="bottom_left")
@@ -291,10 +300,9 @@ class ColorStripStore:
scale: Optional[float] = None, scale: Optional[float] = None,
mirror: Optional[bool] = None, mirror: Optional[bool] = None,
layers: Optional[list] = None, layers: Optional[list] = None,
zones: Optional[list] = None,
visualization_mode: Optional[str] = None, visualization_mode: Optional[str] = None,
audio_device_index: Optional[int] = None, audio_source_id: Optional[str] = None,
audio_loopback: Optional[bool] = None,
audio_channel: Optional[str] = None,
sensitivity: Optional[float] = None, sensitivity: Optional[float] = None,
color_peak: Optional[list] = None, color_peak: Optional[list] = None,
) -> ColorStripSource: ) -> ColorStripSource:
@@ -380,12 +388,8 @@ class ColorStripStore:
elif isinstance(source, AudioColorStripSource): elif isinstance(source, AudioColorStripSource):
if visualization_mode is not None: if visualization_mode is not None:
source.visualization_mode = visualization_mode source.visualization_mode = visualization_mode
if audio_device_index is not None: if audio_source_id is not None:
source.audio_device_index = audio_device_index source.audio_source_id = audio_source_id
if audio_loopback is not None:
source.audio_loopback = bool(audio_loopback)
if audio_channel is not None:
source.audio_channel = audio_channel
if sensitivity is not None: if sensitivity is not None:
source.sensitivity = float(sensitivity) source.sensitivity = float(sensitivity)
if smoothing is not None: if smoothing is not None:
@@ -405,6 +409,11 @@ class ColorStripStore:
source.layers = layers source.layers = layers
if led_count is not None: if led_count is not None:
source.led_count = led_count 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() source.updated_at = datetime.utcnow()
self._save() self._save()
@@ -436,3 +445,14 @@ class ColorStripStore:
names.append(source.name) names.append(source.name)
break break
return names 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

View File

@@ -31,14 +31,14 @@
<button class="theme-toggle" onclick="toggleTheme()" data-i18n-title="theme.toggle" title="Toggle theme"> <button class="theme-toggle" onclick="toggleTheme()" data-i18n-title="theme.toggle" title="Toggle theme">
<span id="theme-icon">🌙</span> <span id="theme-icon">🌙</span>
</button> </button>
<select id="locale-select" onchange="changeLocale()" data-i18n-title="locale.change" title="Change language" style="margin-left: 10px; padding: 6px 12px; border: 1px solid var(--border-color); border-radius: 4px; background: var(--bg-color); color: var(--text-color); font-size: 0.9rem; cursor: pointer;"> <select id="locale-select" onchange="changeLocale()" data-i18n-title="locale.change" title="Change language" style="padding: 4px 8px; border: 1px solid var(--border-color); border-radius: 4px; background: var(--bg-color); color: var(--text-color); font-size: 0.8rem; cursor: pointer;">
<option value="en">English</option> <option value="en">English</option>
<option value="ru">Русский</option> <option value="ru">Русский</option>
</select> </select>
<button id="login-btn" class="btn btn-primary" onclick="showLogin()" style="display: none; padding: 6px 16px; font-size: 0.85rem; margin-left: 10px;"> <button id="login-btn" class="btn btn-primary" onclick="showLogin()" style="display: none; padding: 4px 12px; font-size: 0.8rem;">
🔑 <span data-i18n="auth.login">Login</span> 🔑 <span data-i18n="auth.login">Login</span>
</button> </button>
<button id="logout-btn" class="btn btn-danger" onclick="logout()" style="display: none; padding: 6px 16px; font-size: 0.85rem; margin-left: 10px;"> <button id="logout-btn" class="btn btn-danger" onclick="logout()" style="display: none; padding: 4px 12px; font-size: 0.8rem;">
🚪 🚪
</button> </button>
</div> </div>
@@ -117,6 +117,7 @@
{% include 'modals/stream.html' %} {% include 'modals/stream.html' %}
{% include 'modals/pp-template.html' %} {% include 'modals/pp-template.html' %}
{% include 'modals/profile-editor.html' %} {% include 'modals/profile-editor.html' %}
{% include 'modals/audio-source-editor.html' %}
{% include 'partials/tutorial-overlay.html' %} {% include 'partials/tutorial-overlay.html' %}
{% include 'partials/image-lightbox.html' %} {% include 'partials/image-lightbox.html' %}

View File

@@ -0,0 +1,94 @@
<!-- Audio Source Editor Modal -->
<div id="audio-source-modal" class="modal" role="dialog" aria-labelledby="audio-source-modal-title">
<div class="modal-content">
<div class="modal-header">
<h2 id="audio-source-modal-title" data-i18n="audio_source.add">Add Audio Source</h2>
<button class="modal-close-btn" onclick="closeAudioSourceModal()" aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<form id="audio-source-form" onsubmit="return false;">
<input type="hidden" id="audio-source-id">
<div id="audio-source-error" class="error-message" style="display: none;"></div>
<!-- Name -->
<div class="form-group">
<div class="label-row">
<label for="audio-source-name" data-i18n="audio_source.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="audio_source.name.hint">A descriptive name for this audio source</small>
<input type="text" id="audio-source-name" data-i18n-placeholder="audio_source.name.placeholder" placeholder="System Audio" required>
</div>
<!-- Type -->
<div class="form-group">
<div class="label-row">
<label for="audio-source-type" data-i18n="audio_source.type">Type:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="audio_source.type.hint">Multichannel captures all channels from a physical audio device. Mono extracts a single channel from a multichannel source.</small>
<select id="audio-source-type" onchange="onAudioSourceTypeChange()">
<option value="multichannel" data-i18n="audio_source.type.multichannel">Multichannel</option>
<option value="mono" data-i18n="audio_source.type.mono">Mono</option>
</select>
</div>
<!-- Multichannel fields -->
<div id="audio-source-multichannel-section">
<div class="form-group">
<div class="label-row">
<label for="audio-source-device" data-i18n="audio_source.device">Audio Device:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="audio_source.device.hint">Audio input source. Loopback devices capture system audio output; input devices capture microphone or line-in.</small>
<select id="audio-source-device">
<!-- populated dynamically -->
</select>
</div>
</div>
<!-- Mono fields -->
<div id="audio-source-mono-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label for="audio-source-parent" data-i18n="audio_source.parent">Parent Source:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="audio_source.parent.hint">Multichannel source to extract a channel from</small>
<select id="audio-source-parent">
<!-- populated dynamically with multichannel sources -->
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="audio-source-channel" data-i18n="audio_source.channel">Channel:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="audio_source.channel.hint">Which audio channel to extract from the multichannel source</small>
<select id="audio-source-channel">
<option value="mono" data-i18n="audio_source.channel.mono">Mono (L+R mix)</option>
<option value="left" data-i18n="audio_source.channel.left">Left</option>
<option value="right" data-i18n="audio_source.channel.right">Right</option>
</select>
</div>
</div>
<!-- Description -->
<div class="form-group">
<div class="label-row">
<label for="audio-source-description" data-i18n="audio_source.description">Description (optional):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="audio_source.description.hint">Optional notes about this audio source</small>
<input type="text" id="audio-source-description" data-i18n-placeholder="audio_source.description.placeholder" placeholder="Describe this audio source...">
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeAudioSourceModal()" data-i18n="settings.button.cancel">&times; Cancel</button>
<button class="btn btn-primary" onclick="saveAudioSource()" data-i18n="settings.button.save">&check; Save</button>
</div>
</div>
</div>

View File

@@ -27,6 +27,7 @@
<option value="color_cycle" data-i18n="color_strip.type.color_cycle">Color Cycle</option> <option value="color_cycle" data-i18n="color_strip.type.color_cycle">Color Cycle</option>
<option value="effect" data-i18n="color_strip.type.effect">Procedural Effect</option> <option value="effect" data-i18n="color_strip.type.effect">Procedural Effect</option>
<option value="composite" data-i18n="color_strip.type.composite">Composite</option> <option value="composite" data-i18n="color_strip.type.composite">Composite</option>
<option value="mapped" data-i18n="color_strip.type.mapped">Mapped</option>
<option value="audio" data-i18n="color_strip.type.audio">Audio Reactive</option> <option value="audio" data-i18n="color_strip.type.audio">Audio Reactive</option>
</select> </select>
</div> </div>
@@ -316,6 +317,19 @@
</div> </div>
</div> </div>
<!-- Mapped-specific fields -->
<div id="css-editor-mapped-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label data-i18n="color_strip.mapped.zones">Zones:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.mapped.zones.hint">Each zone maps a color strip source to a specific LED range. Zones are placed side-by-side.</small>
<div id="mapped-zones-list"></div>
<button type="button" class="btn btn-secondary" onclick="mappedAddZone()" data-i18n="color_strip.mapped.add_zone">+ Add Zone</button>
</div>
</div>
<!-- Audio-reactive fields --> <!-- Audio-reactive fields -->
<div id="css-editor-audio-section" style="display:none"> <div id="css-editor-audio-section" style="display:none">
<div class="form-group"> <div class="form-group">
@@ -333,25 +347,12 @@
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
<label for="css-editor-audio-device" data-i18n="color_strip.audio.device">Audio Device:</label> <label for="css-editor-audio-source" data-i18n="color_strip.audio.source">Audio Source:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="color_strip.audio.device.hint">Audio input source. Loopback devices capture system audio output; input devices capture microphone or line-in.</small> <small class="input-hint" style="display:none" data-i18n="color_strip.audio.source.hint">Mono audio source that provides audio data for this visualization. Create and manage audio sources in the Sources tab.</small>
<select id="css-editor-audio-device"> <select id="css-editor-audio-source">
<!-- populated dynamically from /api/v1/audio-devices --> <!-- populated dynamically from /api/v1/audio-sources?source_type=mono -->
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-audio-channel" data-i18n="color_strip.audio.channel">Channel:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.audio.channel.hint">Select which audio channel to visualize. Use Left/Right for stereo setups.</small>
<select id="css-editor-audio-channel">
<option value="mono" data-i18n="color_strip.audio.channel.mono">Mono (L+R mix)</option>
<option value="left" data-i18n="color_strip.audio.channel.left">Left</option>
<option value="right" data-i18n="color_strip.audio.channel.right">Right</option>
</select> </select>
</div> </div>