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:
@@ -11,6 +11,7 @@ from .routes.pattern_templates import router as pattern_templates_router
|
||||
from .routes.picture_targets import router as picture_targets_router
|
||||
from .routes.color_strip_sources import router as color_strip_sources_router
|
||||
from .routes.audio import router as audio_router
|
||||
from .routes.audio_sources import router as audio_sources_router
|
||||
from .routes.profiles import router as profiles_router
|
||||
|
||||
router = APIRouter()
|
||||
@@ -22,6 +23,7 @@ router.include_router(pattern_templates_router)
|
||||
router.include_router(picture_sources_router)
|
||||
router.include_router(color_strip_sources_router)
|
||||
router.include_router(audio_router)
|
||||
router.include_router(audio_sources_router)
|
||||
router.include_router(picture_targets_router)
|
||||
router.include_router(profiles_router)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from wled_controller.storage.pattern_template_store import PatternTemplateStore
|
||||
from wled_controller.storage.picture_source_store import PictureSourceStore
|
||||
from wled_controller.storage.picture_target_store import PictureTargetStore
|
||||
from wled_controller.storage.color_strip_store import ColorStripStore
|
||||
from wled_controller.storage.audio_source_store import AudioSourceStore
|
||||
from wled_controller.storage.profile_store import ProfileStore
|
||||
from wled_controller.core.profiles.profile_engine import ProfileEngine
|
||||
|
||||
@@ -19,6 +20,7 @@ _pattern_template_store: PatternTemplateStore | None = None
|
||||
_picture_source_store: PictureSourceStore | None = None
|
||||
_picture_target_store: PictureTargetStore | None = None
|
||||
_color_strip_store: ColorStripStore | None = None
|
||||
_audio_source_store: AudioSourceStore | None = None
|
||||
_processor_manager: ProcessorManager | None = None
|
||||
_profile_store: ProfileStore | None = None
|
||||
_profile_engine: ProfileEngine | None = None
|
||||
@@ -73,6 +75,13 @@ def get_color_strip_store() -> ColorStripStore:
|
||||
return _color_strip_store
|
||||
|
||||
|
||||
def get_audio_source_store() -> AudioSourceStore:
|
||||
"""Get audio source store dependency."""
|
||||
if _audio_source_store is None:
|
||||
raise RuntimeError("Audio source store not initialized")
|
||||
return _audio_source_store
|
||||
|
||||
|
||||
def get_processor_manager() -> ProcessorManager:
|
||||
"""Get processor manager dependency."""
|
||||
if _processor_manager is None:
|
||||
@@ -103,13 +112,14 @@ def init_dependencies(
|
||||
picture_source_store: PictureSourceStore | None = None,
|
||||
picture_target_store: PictureTargetStore | None = None,
|
||||
color_strip_store: ColorStripStore | None = None,
|
||||
audio_source_store: AudioSourceStore | None = None,
|
||||
profile_store: ProfileStore | None = None,
|
||||
profile_engine: ProfileEngine | None = None,
|
||||
):
|
||||
"""Initialize global dependencies."""
|
||||
global _device_store, _template_store, _processor_manager
|
||||
global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_store
|
||||
global _color_strip_store, _profile_store, _profile_engine
|
||||
global _color_strip_store, _audio_source_store, _profile_store, _profile_engine
|
||||
_device_store = device_store
|
||||
_template_store = template_store
|
||||
_processor_manager = processor_manager
|
||||
@@ -118,5 +128,6 @@ def init_dependencies(
|
||||
_picture_source_store = picture_source_store
|
||||
_picture_target_store = picture_target_store
|
||||
_color_strip_store = color_strip_store
|
||||
_audio_source_store = audio_source_store
|
||||
_profile_store = profile_store
|
||||
_profile_engine = profile_engine
|
||||
|
||||
139
server/src/wled_controller/api/routes/audio_sources.py
Normal file
139
server/src/wled_controller/api/routes/audio_sources.py
Normal 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))
|
||||
@@ -80,10 +80,9 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe
|
||||
frame_interpolation=getattr(source, "frame_interpolation", None),
|
||||
animation=getattr(source, "animation", None),
|
||||
layers=getattr(source, "layers", None),
|
||||
zones=getattr(source, "zones", None),
|
||||
visualization_mode=getattr(source, "visualization_mode", None),
|
||||
audio_device_index=getattr(source, "audio_device_index", None),
|
||||
audio_loopback=getattr(source, "audio_loopback", None),
|
||||
audio_channel=getattr(source, "audio_channel", None),
|
||||
audio_source_id=getattr(source, "audio_source_id", None),
|
||||
sensitivity=getattr(source, "sensitivity", None),
|
||||
color_peak=getattr(source, "color_peak", None),
|
||||
overlay_active=overlay_active,
|
||||
@@ -137,6 +136,8 @@ async def create_color_strip_source(
|
||||
|
||||
layers = [l.model_dump() for l in data.layers] if data.layers is not None else None
|
||||
|
||||
zones = [z.model_dump() for z in data.zones] if data.zones is not None else None
|
||||
|
||||
source = store.create_source(
|
||||
name=data.name,
|
||||
source_type=data.source_type,
|
||||
@@ -162,10 +163,9 @@ async def create_color_strip_source(
|
||||
scale=data.scale,
|
||||
mirror=data.mirror,
|
||||
layers=layers,
|
||||
zones=zones,
|
||||
visualization_mode=data.visualization_mode,
|
||||
audio_device_index=data.audio_device_index,
|
||||
audio_loopback=data.audio_loopback,
|
||||
audio_channel=data.audio_channel,
|
||||
audio_source_id=data.audio_source_id,
|
||||
sensitivity=data.sensitivity,
|
||||
color_peak=data.color_peak,
|
||||
)
|
||||
@@ -211,6 +211,8 @@ async def update_color_strip_source(
|
||||
|
||||
layers = [l.model_dump() for l in data.layers] if data.layers is not None else None
|
||||
|
||||
zones = [z.model_dump() for z in data.zones] if data.zones is not None else None
|
||||
|
||||
source = store.update_source(
|
||||
source_id=source_id,
|
||||
name=data.name,
|
||||
@@ -236,10 +238,9 @@ async def update_color_strip_source(
|
||||
scale=data.scale,
|
||||
mirror=data.mirror,
|
||||
layers=layers,
|
||||
zones=zones,
|
||||
visualization_mode=data.visualization_mode,
|
||||
audio_device_index=data.audio_device_index,
|
||||
audio_loopback=data.audio_loopback,
|
||||
audio_channel=data.audio_channel,
|
||||
audio_source_id=data.audio_source_id,
|
||||
sensitivity=data.sensitivity,
|
||||
color_peak=data.color_peak,
|
||||
)
|
||||
@@ -284,6 +285,14 @@ async def delete_color_strip_source(
|
||||
detail=f"Color strip source is used as a layer in composite source(s): {names}. "
|
||||
"Remove it from the composite(s) first.",
|
||||
)
|
||||
mapped_names = store.get_mapped_referencing(source_id)
|
||||
if mapped_names:
|
||||
names = ", ".join(mapped_names)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Color strip source is used as a zone in mapped source(s): {names}. "
|
||||
"Remove it from the mapped source(s) first.",
|
||||
)
|
||||
store.delete_source(source_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
53
server/src/wled_controller/api/schemas/audio_sources.py
Normal file
53
server/src/wled_controller/api/schemas/audio_sources.py
Normal 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")
|
||||
@@ -36,11 +36,20 @@ class CompositeLayer(BaseModel):
|
||||
enabled: bool = Field(default=True, description="Whether this layer is active")
|
||||
|
||||
|
||||
class MappedZone(BaseModel):
|
||||
"""A single zone in a mapped color strip source."""
|
||||
|
||||
source_id: str = Field(description="ID of the zone's color strip source")
|
||||
start: int = Field(default=0, ge=0, description="First LED index (0-based)")
|
||||
end: int = Field(default=0, ge=0, description="Last LED index (exclusive); 0 = auto-fill")
|
||||
reverse: bool = Field(default=False, description="Reverse zone output")
|
||||
|
||||
|
||||
class ColorStripSourceCreate(BaseModel):
|
||||
"""Request to create a color strip source."""
|
||||
|
||||
name: str = Field(description="Source name", min_length=1, max_length=100)
|
||||
source_type: Literal["picture", "static", "gradient", "color_cycle", "effect", "composite", "audio"] = Field(default="picture", description="Source type")
|
||||
source_type: Literal["picture", "static", "gradient", "color_cycle", "effect", "composite", "mapped", "audio"] = Field(default="picture", description="Source type")
|
||||
# picture-type fields
|
||||
picture_source_id: str = Field(default="", description="Picture source ID (for picture type)")
|
||||
brightness: float = Field(default=1.0, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0)
|
||||
@@ -65,13 +74,13 @@ class ColorStripSourceCreate(BaseModel):
|
||||
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode (meteor)")
|
||||
# composite-type fields
|
||||
layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type")
|
||||
# mapped-type fields
|
||||
zones: Optional[List[MappedZone]] = Field(None, description="Zones for mapped type")
|
||||
# audio-type fields
|
||||
visualization_mode: Optional[str] = Field(None, description="Audio visualization: spectrum|beat_pulse|vu_meter")
|
||||
audio_device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)")
|
||||
audio_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback), False for mic/line-in")
|
||||
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID (for audio type)")
|
||||
sensitivity: Optional[float] = Field(None, description="Audio sensitivity/gain 0.1-5.0", ge=0.1, le=5.0)
|
||||
color_peak: Optional[List[int]] = Field(None, description="Peak/high RGB color for VU meter [R,G,B]")
|
||||
audio_channel: Optional[str] = Field(None, description="Audio channel: mono|left|right")
|
||||
# shared
|
||||
led_count: int = Field(default=0, description="Total LED count (0 = auto from calibration / device)", ge=0)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
@@ -107,13 +116,13 @@ class ColorStripSourceUpdate(BaseModel):
|
||||
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode")
|
||||
# composite-type fields
|
||||
layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type")
|
||||
# mapped-type fields
|
||||
zones: Optional[List[MappedZone]] = Field(None, description="Zones for mapped type")
|
||||
# audio-type fields
|
||||
visualization_mode: Optional[str] = Field(None, description="Audio visualization: spectrum|beat_pulse|vu_meter")
|
||||
audio_device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)")
|
||||
audio_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback), False for mic/line-in")
|
||||
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID (for audio type)")
|
||||
sensitivity: Optional[float] = Field(None, description="Audio sensitivity/gain 0.1-5.0", ge=0.1, le=5.0)
|
||||
color_peak: Optional[List[int]] = Field(None, description="Peak/high RGB color for VU meter [R,G,B]")
|
||||
audio_channel: Optional[str] = Field(None, description="Audio channel: mono|left|right")
|
||||
# shared
|
||||
led_count: Optional[int] = Field(None, description="Total LED count (0 = auto from calibration / device)", ge=0)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
@@ -151,13 +160,13 @@ class ColorStripSourceResponse(BaseModel):
|
||||
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode")
|
||||
# composite-type fields
|
||||
layers: Optional[List[dict]] = Field(None, description="Layers for composite type")
|
||||
# mapped-type fields
|
||||
zones: Optional[List[dict]] = Field(None, description="Zones for mapped type")
|
||||
# audio-type fields
|
||||
visualization_mode: Optional[str] = Field(None, description="Audio visualization mode")
|
||||
audio_device_index: Optional[int] = Field(None, description="Audio device index")
|
||||
audio_loopback: Optional[bool] = Field(None, description="WASAPI loopback mode")
|
||||
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
|
||||
sensitivity: Optional[float] = Field(None, description="Audio sensitivity")
|
||||
color_peak: Optional[List[int]] = Field(None, description="Peak color [R,G,B]")
|
||||
audio_channel: Optional[str] = Field(None, description="Audio channel: mono|left|right")
|
||||
# shared
|
||||
led_count: int = Field(0, description="Total LED count (0 = auto from calibration / device)")
|
||||
description: Optional[str] = Field(None, description="Description")
|
||||
|
||||
@@ -34,6 +34,7 @@ class StorageConfig(BaseSettings):
|
||||
picture_targets_file: str = "data/picture_targets.json"
|
||||
pattern_templates_file: str = "data/pattern_templates.json"
|
||||
color_strip_sources_file: str = "data/color_strip_sources.json"
|
||||
audio_sources_file: str = "data/audio_sources.json"
|
||||
profiles_file: str = "data/profiles.json"
|
||||
|
||||
|
||||
|
||||
@@ -35,8 +35,9 @@ class AudioColorStripStream(ColorStripStream):
|
||||
thread, double-buffered output, configure() for auto-sizing.
|
||||
"""
|
||||
|
||||
def __init__(self, source, audio_capture_manager: AudioCaptureManager):
|
||||
def __init__(self, source, audio_capture_manager: AudioCaptureManager, audio_source_store=None):
|
||||
self._audio_capture_manager = audio_capture_manager
|
||||
self._audio_source_store = audio_source_store
|
||||
self._audio_stream = None # acquired on start
|
||||
|
||||
self._colors_lock = threading.Lock()
|
||||
@@ -55,8 +56,6 @@ class AudioColorStripStream(ColorStripStream):
|
||||
|
||||
def _update_from_source(self, source) -> None:
|
||||
self._visualization_mode = getattr(source, "visualization_mode", "spectrum")
|
||||
self._audio_device_index = getattr(source, "audio_device_index", -1)
|
||||
self._audio_loopback = bool(getattr(source, "audio_loopback", True))
|
||||
self._sensitivity = float(getattr(source, "sensitivity", 1.0))
|
||||
self._smoothing = float(getattr(source, "smoothing", 0.3))
|
||||
self._palette_name = getattr(source, "palette", "rainbow")
|
||||
@@ -68,7 +67,26 @@ class AudioColorStripStream(ColorStripStream):
|
||||
self._auto_size = not source.led_count
|
||||
self._led_count = source.led_count if source.led_count and source.led_count > 0 else 1
|
||||
self._mirror = bool(getattr(source, "mirror", False))
|
||||
self._audio_channel = getattr(source, "audio_channel", "mono") # mono | left | right
|
||||
|
||||
# Resolve audio device/channel via audio_source_id
|
||||
audio_source_id = getattr(source, "audio_source_id", "")
|
||||
self._audio_source_id = audio_source_id
|
||||
if audio_source_id and self._audio_source_store:
|
||||
try:
|
||||
device_index, is_loopback, channel = self._audio_source_store.resolve_mono_source(audio_source_id)
|
||||
self._audio_device_index = device_index
|
||||
self._audio_loopback = is_loopback
|
||||
self._audio_channel = channel
|
||||
except ValueError as e:
|
||||
logger.warning(f"Failed to resolve audio source {audio_source_id}: {e}")
|
||||
self._audio_device_index = -1
|
||||
self._audio_loopback = True
|
||||
self._audio_channel = "mono"
|
||||
else:
|
||||
self._audio_device_index = -1
|
||||
self._audio_loopback = True
|
||||
self._audio_channel = "mono"
|
||||
|
||||
with self._colors_lock:
|
||||
self._colors: Optional[np.ndarray] = None
|
||||
|
||||
|
||||
@@ -56,16 +56,18 @@ class ColorStripStreamManager:
|
||||
keyed by ``{css_id}:{consumer_id}``.
|
||||
"""
|
||||
|
||||
def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None):
|
||||
def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None, audio_source_store=None):
|
||||
"""
|
||||
Args:
|
||||
color_strip_store: ColorStripStore for resolving source configs
|
||||
live_stream_manager: LiveStreamManager for acquiring picture streams
|
||||
audio_capture_manager: AudioCaptureManager for audio-reactive sources
|
||||
audio_source_store: AudioSourceStore for resolving audio source chains
|
||||
"""
|
||||
self._color_strip_store = color_strip_store
|
||||
self._live_stream_manager = live_stream_manager
|
||||
self._audio_capture_manager = audio_capture_manager
|
||||
self._audio_source_store = audio_source_store
|
||||
self._streams: Dict[str, _ColorStripEntry] = {}
|
||||
|
||||
def _resolve_key(self, css_id: str, consumer_id: str) -> str:
|
||||
@@ -104,10 +106,13 @@ class ColorStripStreamManager:
|
||||
if not source.sharable:
|
||||
if source.source_type == "audio":
|
||||
from wled_controller.core.processing.audio_stream import AudioColorStripStream
|
||||
css_stream = AudioColorStripStream(source, self._audio_capture_manager)
|
||||
css_stream = AudioColorStripStream(source, self._audio_capture_manager, self._audio_source_store)
|
||||
elif source.source_type == "composite":
|
||||
from wled_controller.core.processing.composite_stream import CompositeColorStripStream
|
||||
css_stream = CompositeColorStripStream(source, self)
|
||||
elif source.source_type == "mapped":
|
||||
from wled_controller.core.processing.mapped_stream import MappedColorStripStream
|
||||
css_stream = MappedColorStripStream(source, self)
|
||||
else:
|
||||
stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type)
|
||||
if not stream_cls:
|
||||
|
||||
212
server/src/wled_controller/core/processing/mapped_stream.py
Normal file
212
server/src/wled_controller/core/processing/mapped_stream.py
Normal 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))
|
||||
@@ -64,7 +64,7 @@ class ProcessorManager:
|
||||
Targets are registered for processing via polymorphic TargetProcessor subclasses.
|
||||
"""
|
||||
|
||||
def __init__(self, picture_source_store=None, capture_template_store=None, pp_template_store=None, pattern_template_store=None, device_store=None, color_strip_store=None):
|
||||
def __init__(self, picture_source_store=None, capture_template_store=None, pp_template_store=None, pattern_template_store=None, device_store=None, color_strip_store=None, audio_source_store=None):
|
||||
"""Initialize processor manager."""
|
||||
self._devices: Dict[str, DeviceState] = {}
|
||||
self._processors: Dict[str, TargetProcessor] = {}
|
||||
@@ -77,6 +77,7 @@ class ProcessorManager:
|
||||
self._pattern_template_store = pattern_template_store
|
||||
self._device_store = device_store
|
||||
self._color_strip_store = color_strip_store
|
||||
self._audio_source_store = audio_source_store
|
||||
self._live_stream_manager = LiveStreamManager(
|
||||
picture_source_store, capture_template_store, pp_template_store
|
||||
)
|
||||
@@ -85,6 +86,7 @@ class ProcessorManager:
|
||||
color_strip_store=color_strip_store,
|
||||
live_stream_manager=self._live_stream_manager,
|
||||
audio_capture_manager=self._audio_capture_manager,
|
||||
audio_source_store=audio_source_store,
|
||||
)
|
||||
self._overlay_manager = OverlayManager()
|
||||
self._event_queues: List[asyncio.Queue] = []
|
||||
|
||||
@@ -23,6 +23,7 @@ from wled_controller.storage.pattern_template_store import PatternTemplateStore
|
||||
from wled_controller.storage.picture_source_store import PictureSourceStore
|
||||
from wled_controller.storage.picture_target_store import PictureTargetStore
|
||||
from wled_controller.storage.color_strip_store import ColorStripStore
|
||||
from wled_controller.storage.audio_source_store import AudioSourceStore
|
||||
from wled_controller.storage.profile_store import ProfileStore
|
||||
from wled_controller.core.profiles.profile_engine import ProfileEngine
|
||||
from wled_controller.utils import setup_logging, get_logger
|
||||
@@ -42,8 +43,12 @@ picture_source_store = PictureSourceStore(config.storage.picture_sources_file)
|
||||
picture_target_store = PictureTargetStore(config.storage.picture_targets_file)
|
||||
pattern_template_store = PatternTemplateStore(config.storage.pattern_templates_file)
|
||||
color_strip_store = ColorStripStore(config.storage.color_strip_sources_file)
|
||||
audio_source_store = AudioSourceStore(config.storage.audio_sources_file)
|
||||
profile_store = ProfileStore(config.storage.profiles_file)
|
||||
|
||||
# Migrate embedded audio config from CSS entities to audio sources
|
||||
audio_source_store.migrate_from_css(color_strip_store)
|
||||
|
||||
processor_manager = ProcessorManager(
|
||||
picture_source_store=picture_source_store,
|
||||
capture_template_store=template_store,
|
||||
@@ -51,6 +56,7 @@ processor_manager = ProcessorManager(
|
||||
pattern_template_store=pattern_template_store,
|
||||
device_store=device_store,
|
||||
color_strip_store=color_strip_store,
|
||||
audio_source_store=audio_source_store,
|
||||
)
|
||||
|
||||
|
||||
@@ -94,6 +100,7 @@ async def lifespan(app: FastAPI):
|
||||
picture_source_store=picture_source_store,
|
||||
picture_target_store=picture_target_store,
|
||||
color_strip_store=color_strip_store,
|
||||
audio_source_store=audio_source_store,
|
||||
profile_store=profile_store,
|
||||
profile_engine=profile_engine,
|
||||
)
|
||||
|
||||
@@ -2,8 +2,8 @@ header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 0 10px;
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 0 6px;
|
||||
margin-bottom: 6px;
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
}
|
||||
@@ -11,11 +11,11 @@ header {
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
font-size: 1.25rem;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ h2 {
|
||||
.server-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-link {
|
||||
@@ -57,7 +57,7 @@ h2 {
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 1.5rem;
|
||||
font-size: 1rem;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@@ -156,12 +156,12 @@ h2 {
|
||||
.theme-toggle {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 6px 12px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
font-size: 1rem;
|
||||
transition: transform 0.2s;
|
||||
margin-left: 10px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
@@ -170,14 +170,14 @@ h2 {
|
||||
|
||||
/* Footer */
|
||||
.app-footer {
|
||||
margin-top: 20px;
|
||||
padding: 15px 0;
|
||||
margin-top: 12px;
|
||||
padding: 6px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.footer-content p {
|
||||
@@ -203,7 +203,7 @@ h2 {
|
||||
@media (max-width: 768px) {
|
||||
header {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ import {
|
||||
import {
|
||||
loadTargetsTab, loadTargets, switchTargetSubTab,
|
||||
showTargetEditor, closeTargetEditorModal, forceCloseTargetEditorModal, saveTargetEditor,
|
||||
addTargetSegment, removeTargetSegment,
|
||||
addTargetSegment, removeTargetSegment, syncSegmentsMappedMode,
|
||||
startTargetProcessing, stopTargetProcessing,
|
||||
startTargetOverlay, stopTargetOverlay, deleteTarget,
|
||||
cloneTarget,
|
||||
@@ -97,11 +97,18 @@ import {
|
||||
onCSSTypeChange, onEffectTypeChange, onAnimationTypeChange, updateEffectPreview,
|
||||
colorCycleAddColor, colorCycleRemoveColor,
|
||||
compositeAddLayer, compositeRemoveLayer,
|
||||
mappedAddZone, mappedRemoveZone,
|
||||
onAudioVizChange,
|
||||
applyGradientPreset,
|
||||
cloneColorStrip,
|
||||
} from './features/color-strips.js';
|
||||
|
||||
// Layer 5: audio sources
|
||||
import {
|
||||
showAudioSourceModal, closeAudioSourceModal, saveAudioSource,
|
||||
editAudioSource, deleteAudioSource, onAudioSourceTypeChange,
|
||||
} from './features/audio-sources.js';
|
||||
|
||||
// Layer 5: calibration
|
||||
import {
|
||||
showCalibration, closeCalibrationModal, forceCloseCalibrationModal, saveCalibration,
|
||||
@@ -278,6 +285,7 @@ Object.assign(window, {
|
||||
saveTargetEditor,
|
||||
addTargetSegment,
|
||||
removeTargetSegment,
|
||||
syncSegmentsMappedMode,
|
||||
startTargetProcessing,
|
||||
stopTargetProcessing,
|
||||
startTargetOverlay,
|
||||
@@ -299,10 +307,20 @@ Object.assign(window, {
|
||||
colorCycleRemoveColor,
|
||||
compositeAddLayer,
|
||||
compositeRemoveLayer,
|
||||
mappedAddZone,
|
||||
mappedRemoveZone,
|
||||
onAudioVizChange,
|
||||
applyGradientPreset,
|
||||
cloneColorStrip,
|
||||
|
||||
// audio sources
|
||||
showAudioSourceModal,
|
||||
closeAudioSourceModal,
|
||||
saveAudioSource,
|
||||
editAudioSource,
|
||||
deleteAudioSource,
|
||||
onAudioSourceTypeChange,
|
||||
|
||||
// calibration
|
||||
showCalibration,
|
||||
closeCalibrationModal,
|
||||
|
||||
@@ -165,6 +165,10 @@ export const PATTERN_RECT_BORDERS = [
|
||||
'#4CAF50', '#2196F3', '#FF9800', '#9C27B0', '#00BCD4', '#F44336', '#FFEB3B', '#795548',
|
||||
];
|
||||
|
||||
// Audio sources
|
||||
export let _cachedAudioSources = [];
|
||||
export function set_cachedAudioSources(v) { _cachedAudioSources = v; }
|
||||
|
||||
// Profiles
|
||||
export let _profilesCache = null;
|
||||
export function set_profilesCache(v) { _profilesCache = v; }
|
||||
|
||||
193
server/src/wled_controller/static/js/features/audio-sources.js
Normal file
193
server/src/wled_controller/static/js/features/audio-sources.js
Normal 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('');
|
||||
}
|
||||
@@ -39,8 +39,9 @@ class CSSEditorModal extends Modal {
|
||||
effect_scale: document.getElementById('css-editor-effect-scale').value,
|
||||
effect_mirror: document.getElementById('css-editor-effect-mirror').checked,
|
||||
composite_layers: JSON.stringify(_compositeLayers),
|
||||
mapped_zones: JSON.stringify(_mappedZones),
|
||||
audio_viz: document.getElementById('css-editor-audio-viz').value,
|
||||
audio_device: document.getElementById('css-editor-audio-device').value,
|
||||
audio_source: document.getElementById('css-editor-audio-source').value,
|
||||
audio_sensitivity: document.getElementById('css-editor-audio-sensitivity').value,
|
||||
audio_smoothing: document.getElementById('css-editor-audio-smoothing').value,
|
||||
audio_palette: document.getElementById('css-editor-audio-palette').value,
|
||||
@@ -63,6 +64,7 @@ export function onCSSTypeChange() {
|
||||
document.getElementById('css-editor-gradient-section').style.display = type === 'gradient' ? '' : 'none';
|
||||
document.getElementById('css-editor-effect-section').style.display = type === 'effect' ? '' : 'none';
|
||||
document.getElementById('css-editor-composite-section').style.display = type === 'composite' ? '' : 'none';
|
||||
document.getElementById('css-editor-mapped-section').style.display = type === 'mapped' ? '' : 'none';
|
||||
document.getElementById('css-editor-audio-section').style.display = type === 'audio' ? '' : 'none';
|
||||
|
||||
if (type === 'effect') onEffectTypeChange();
|
||||
@@ -97,14 +99,16 @@ export function onCSSTypeChange() {
|
||||
}
|
||||
_syncAnimationSpeedState();
|
||||
|
||||
// LED count — not needed for composite/audio (uses device count)
|
||||
// LED count — not needed for composite/mapped/audio (uses device count)
|
||||
document.getElementById('css-editor-led-count-group').style.display =
|
||||
(type === 'composite' || type === 'audio') ? 'none' : '';
|
||||
(type === 'composite' || type === 'mapped' || type === 'audio') ? 'none' : '';
|
||||
|
||||
if (type === 'audio') {
|
||||
_loadAudioDevices();
|
||||
_loadAudioSources();
|
||||
} else if (type === 'composite') {
|
||||
_compositeRenderList();
|
||||
} else if (type === 'mapped') {
|
||||
_mappedRenderList();
|
||||
} else if (type === 'gradient') {
|
||||
requestAnimationFrame(() => gradientRenderAll());
|
||||
}
|
||||
@@ -391,6 +395,107 @@ function _loadCompositeState(css) {
|
||||
_compositeRenderList();
|
||||
}
|
||||
|
||||
/* ── Mapped zone helpers ──────────────────────────────────────── */
|
||||
|
||||
let _mappedZones = [];
|
||||
let _mappedAvailableSources = []; // non-mapped sources for zone dropdowns
|
||||
|
||||
function _mappedRenderList() {
|
||||
const list = document.getElementById('mapped-zones-list');
|
||||
if (!list) return;
|
||||
list.innerHTML = _mappedZones.map((zone, i) => {
|
||||
const srcOptions = _mappedAvailableSources.map(s =>
|
||||
`<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')}">×</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 ──────────────────────────────── */
|
||||
|
||||
export function onAudioVizChange() {
|
||||
@@ -405,24 +510,22 @@ export function onAudioVizChange() {
|
||||
document.getElementById('css-editor-audio-mirror-group').style.display = viz === 'spectrum' ? '' : 'none';
|
||||
}
|
||||
|
||||
async function _loadAudioDevices() {
|
||||
const select = document.getElementById('css-editor-audio-device');
|
||||
async function _loadAudioSources() {
|
||||
const select = document.getElementById('css-editor-audio-source');
|
||||
if (!select) return;
|
||||
try {
|
||||
const resp = await fetchWithAuth('/audio-devices');
|
||||
const resp = await fetchWithAuth('/audio-sources?source_type=mono');
|
||||
if (!resp.ok) throw new Error('fetch failed');
|
||||
const data = await resp.json();
|
||||
const devices = data.devices || [];
|
||||
select.innerHTML = devices.map(d => {
|
||||
const label = d.is_loopback ? `🔊 ${d.name}` : `🎤 ${d.name}`;
|
||||
const val = `${d.index}:${d.is_loopback ? '1' : '0'}`;
|
||||
return `<option value="${val}">${escapeHtml(label)}</option>`;
|
||||
}).join('');
|
||||
if (devices.length === 0) {
|
||||
select.innerHTML = '<option value="-1:1">Default</option>';
|
||||
const sources = data.sources || [];
|
||||
select.innerHTML = sources.map(s =>
|
||||
`<option value="${s.id}">${escapeHtml(s.name)}</option>`
|
||||
).join('');
|
||||
if (sources.length === 0) {
|
||||
select.innerHTML = '';
|
||||
}
|
||||
} 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-val').textContent = parseFloat(smoothing).toFixed(2);
|
||||
|
||||
document.getElementById('css-editor-audio-channel').value = css.audio_channel || 'mono';
|
||||
document.getElementById('css-editor-audio-palette').value = css.palette || 'rainbow';
|
||||
document.getElementById('css-editor-audio-color').value = rgbArrayToHex(css.color || [0, 255, 0]);
|
||||
document.getElementById('css-editor-audio-color-peak').value = rgbArrayToHex(css.color_peak || [255, 0, 0]);
|
||||
document.getElementById('css-editor-audio-mirror').checked = css.mirror || false;
|
||||
|
||||
// Set audio device selector to match stored values
|
||||
const deviceIdx = css.audio_device_index ?? -1;
|
||||
const loopback = css.audio_loopback !== false ? '1' : '0';
|
||||
const deviceVal = `${deviceIdx}:${loopback}`;
|
||||
const select = document.getElementById('css-editor-audio-device');
|
||||
if (select) {
|
||||
// Try exact match, fall back to first option
|
||||
const opt = Array.from(select.options).find(o => o.value === deviceVal);
|
||||
if (opt) select.value = deviceVal;
|
||||
// Set audio source selector
|
||||
const select = document.getElementById('css-editor-audio-source');
|
||||
if (select && css.audio_source_id) {
|
||||
select.value = css.audio_source_id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,7 +559,6 @@ function _resetAudioState() {
|
||||
document.getElementById('css-editor-audio-sensitivity-val').textContent = '1.0';
|
||||
document.getElementById('css-editor-audio-smoothing').value = 0.3;
|
||||
document.getElementById('css-editor-audio-smoothing-val').textContent = '0.30';
|
||||
document.getElementById('css-editor-audio-channel').value = 'mono';
|
||||
document.getElementById('css-editor-audio-palette').value = 'rainbow';
|
||||
document.getElementById('css-editor-audio-color').value = '#00ff00';
|
||||
document.getElementById('css-editor-audio-color-peak').value = '#ff0000';
|
||||
@@ -477,6 +573,7 @@ export function createColorStripCard(source, pictureSourceMap) {
|
||||
const isColorCycle = source.source_type === 'color_cycle';
|
||||
const isEffect = source.source_type === 'effect';
|
||||
const isComposite = source.source_type === 'composite';
|
||||
const isMapped = source.source_type === 'mapped';
|
||||
const isAudio = source.source_type === 'audio';
|
||||
|
||||
const anim = (isStatic || isGradient) && source.animation && source.animation.enabled ? source.animation : null;
|
||||
@@ -543,15 +640,20 @@ export function createColorStripCard(source, pictureSourceMap) {
|
||||
<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>` : ''}
|
||||
`;
|
||||
} 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) {
|
||||
const vizLabel = t('color_strip.audio.viz.' + (source.visualization_mode || 'spectrum')) || source.visualization_mode || 'spectrum';
|
||||
const sensitivityVal = (source.sensitivity || 1.0).toFixed(1);
|
||||
const ch = source.audio_channel || 'mono';
|
||||
const chBadge = ch !== 'mono' ? `<span class="stream-card-prop" title="${t('color_strip.audio.channel')}">${ch === 'left' ? 'L' : 'R'}</span>` : '';
|
||||
const srcLabel = source.audio_source_id || '—';
|
||||
propsHtml = `
|
||||
<span class="stream-card-prop">🎵 ${escapeHtml(vizLabel)}</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>` : ''}
|
||||
`;
|
||||
} else {
|
||||
@@ -567,8 +669,8 @@ export function createColorStripCard(source, pictureSourceMap) {
|
||||
`;
|
||||
}
|
||||
|
||||
const icon = isStatic ? '🎨' : isColorCycle ? '🔄' : isGradient ? '🌈' : isEffect ? '⚡' : isComposite ? '🔗' : isAudio ? '🎵' : '🎞️';
|
||||
const calibrationBtn = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isAudio)
|
||||
const icon = isStatic ? '🎨' : isColorCycle ? '🔄' : isGradient ? '🌈' : isEffect ? '⚡' : isComposite ? '🔗' : isMapped ? '📍' : isAudio ? '🎵' : '🎞️';
|
||||
const calibrationBtn = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isMapped && !isAudio)
|
||||
? `<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 =>
|
||||
s.source_type !== 'composite' && (!cssId || s.id !== cssId)
|
||||
);
|
||||
_mappedAvailableSources = allCssSources.filter(s =>
|
||||
s.source_type !== 'mapped' && (!cssId || s.id !== cssId)
|
||||
);
|
||||
|
||||
const sourceSelect = document.getElementById('css-editor-picture-source');
|
||||
sourceSelect.innerHTML = '';
|
||||
@@ -648,10 +753,12 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
|
||||
document.getElementById('css-editor-effect-scale-val').textContent = parseFloat(css.scale ?? 1.0).toFixed(1);
|
||||
document.getElementById('css-editor-effect-mirror').checked = css.mirror || false;
|
||||
} else if (sourceType === 'audio') {
|
||||
await _loadAudioDevices();
|
||||
await _loadAudioSources();
|
||||
_loadAudioState(css);
|
||||
} else if (sourceType === 'composite') {
|
||||
_loadCompositeState(css);
|
||||
} else if (sourceType === 'mapped') {
|
||||
_loadMappedState(css);
|
||||
} else {
|
||||
sourceSelect.value = css.picture_source_id || '';
|
||||
|
||||
@@ -687,11 +794,15 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
|
||||
document.getElementById('css-editor-id').value = css.id;
|
||||
document.getElementById('css-editor-name').value = css.name;
|
||||
|
||||
// Exclude self from composite sources when editing
|
||||
// Exclude self from composite/mapped sources when editing
|
||||
if (css.source_type === 'composite') {
|
||||
_compositeAvailableSources = allCssSources.filter(s =>
|
||||
s.source_type !== 'composite' && s.id !== css.id
|
||||
);
|
||||
} else if (css.source_type === 'mapped') {
|
||||
_mappedAvailableSources = allCssSources.filter(s =>
|
||||
s.source_type !== 'mapped' && s.id !== css.id
|
||||
);
|
||||
}
|
||||
|
||||
await _populateFromCSS(css);
|
||||
@@ -731,6 +842,7 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
|
||||
document.getElementById('css-editor-effect-scale-val').textContent = '1.0';
|
||||
document.getElementById('css-editor-effect-mirror').checked = false;
|
||||
_loadCompositeState(null);
|
||||
_resetMappedState();
|
||||
_resetAudioState();
|
||||
document.getElementById('css-editor-title').textContent = t('color_strip.add');
|
||||
document.getElementById('css-editor-gradient-preset').value = '';
|
||||
@@ -820,14 +932,10 @@ export async function saveCSSEditor() {
|
||||
}
|
||||
if (!cssId) payload.source_type = 'effect';
|
||||
} else if (sourceType === 'audio') {
|
||||
const deviceVal = document.getElementById('css-editor-audio-device').value || '-1:1';
|
||||
const [devIdx, devLoop] = deviceVal.split(':');
|
||||
payload = {
|
||||
name,
|
||||
visualization_mode: document.getElementById('css-editor-audio-viz').value,
|
||||
audio_device_index: parseInt(devIdx) || -1,
|
||||
audio_loopback: devLoop !== '0',
|
||||
audio_channel: document.getElementById('css-editor-audio-channel').value,
|
||||
audio_source_id: document.getElementById('css-editor-audio-source').value || null,
|
||||
sensitivity: parseFloat(document.getElementById('css-editor-audio-sensitivity').value),
|
||||
smoothing: parseFloat(document.getElementById('css-editor-audio-smoothing').value),
|
||||
palette: document.getElementById('css-editor-audio-palette').value,
|
||||
@@ -852,6 +960,15 @@ export async function saveCSSEditor() {
|
||||
layers,
|
||||
};
|
||||
if (!cssId) payload.source_type = 'composite';
|
||||
} else if (sourceType === 'mapped') {
|
||||
const zones = _mappedGetZones();
|
||||
const hasEmpty = zones.some(z => !z.source_id);
|
||||
if (hasEmpty) {
|
||||
cssEditorModal.showError(t('color_strip.mapped.error.no_source'));
|
||||
return;
|
||||
}
|
||||
payload = { name, zones };
|
||||
if (!cssId) payload.source_type = 'mapped';
|
||||
} else {
|
||||
payload = {
|
||||
name,
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
_currentTestStreamId, set_currentTestStreamId,
|
||||
_currentTestPPTemplateId, set_currentTestPPTemplateId,
|
||||
_lastValidatedImageSource, set_lastValidatedImageSource,
|
||||
_cachedAudioSources, set_cachedAudioSources,
|
||||
apiKey,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
@@ -436,11 +437,12 @@ export async function deleteTemplate(templateId) {
|
||||
|
||||
export async function loadPictureSources() {
|
||||
try {
|
||||
const [filtersResp, ppResp, captResp, streamsResp] = await Promise.all([
|
||||
const [filtersResp, ppResp, captResp, streamsResp, audioResp] = await Promise.all([
|
||||
_availableFilters.length === 0 ? fetchWithAuth('/filters') : Promise.resolve(null),
|
||||
fetchWithAuth('/postprocessing-templates'),
|
||||
fetchWithAuth('/capture-templates'),
|
||||
fetchWithAuth('/picture-sources')
|
||||
fetchWithAuth('/picture-sources'),
|
||||
fetchWithAuth('/audio-sources'),
|
||||
]);
|
||||
|
||||
if (filtersResp && filtersResp.ok) {
|
||||
@@ -455,6 +457,10 @@ export async function loadPictureSources() {
|
||||
const cd = await captResp.json();
|
||||
set_cachedCaptureTemplates(cd.templates || []);
|
||||
}
|
||||
if (audioResp && audioResp.ok) {
|
||||
const ad = await audioResp.json();
|
||||
set_cachedAudioSources(ad.sources || []);
|
||||
}
|
||||
if (!streamsResp.ok) throw new Error(`Failed to load streams: ${streamsResp.status}`);
|
||||
const data = await streamsResp.json();
|
||||
set_cachedStreams(data.streams || []);
|
||||
@@ -596,21 +602,60 @@ function renderPictureSourcesList(streams) {
|
||||
const processedStreams = streams.filter(s => s.stream_type === 'processed');
|
||||
const staticImageStreams = streams.filter(s => s.stream_type === 'static_image');
|
||||
|
||||
const multichannelSources = _cachedAudioSources.filter(s => s.source_type === 'multichannel');
|
||||
const monoSources = _cachedAudioSources.filter(s => s.source_type === 'mono');
|
||||
|
||||
const addStreamCard = (type) => `
|
||||
<div class="template-card add-template-card" onclick="showAddStreamModal('${type}')">
|
||||
<div class="add-template-icon">+</div>
|
||||
</div>`;
|
||||
|
||||
const tabs = [
|
||||
{ key: 'raw', icon: '🖥️', titleKey: 'streams.group.raw', streams: rawStreams },
|
||||
{ key: 'static_image', icon: '🖼️', titleKey: 'streams.group.static_image', streams: staticImageStreams },
|
||||
{ key: 'processed', icon: '🎨', titleKey: 'streams.group.processed', streams: processedStreams },
|
||||
{ key: 'raw', icon: '🖥️', titleKey: 'streams.group.raw', count: rawStreams.length },
|
||||
{ key: 'static_image', icon: '🖼️', titleKey: 'streams.group.static_image', count: staticImageStreams.length },
|
||||
{ key: 'processed', icon: '🎨', titleKey: 'streams.group.processed', count: processedStreams.length },
|
||||
{ key: 'audio', icon: '🔊', titleKey: 'streams.group.audio', count: _cachedAudioSources.length },
|
||||
];
|
||||
|
||||
const tabBar = `<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>`;
|
||||
|
||||
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')}">✕</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 => {
|
||||
let panelContent = '';
|
||||
|
||||
@@ -619,7 +664,7 @@ function renderPictureSourcesList(streams) {
|
||||
<div class="subtab-section">
|
||||
<h3 class="subtab-section-header">${t('streams.section.streams')}</h3>
|
||||
<div class="templates-grid">
|
||||
${tab.streams.map(renderStreamCard).join('')}
|
||||
${rawStreams.map(renderStreamCard).join('')}
|
||||
${addStreamCard(tab.key)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -637,7 +682,7 @@ function renderPictureSourcesList(streams) {
|
||||
<div class="subtab-section">
|
||||
<h3 class="subtab-section-header">${t('streams.section.streams')}</h3>
|
||||
<div class="templates-grid">
|
||||
${tab.streams.map(renderStreamCard).join('')}
|
||||
${processedStreams.map(renderStreamCard).join('')}
|
||||
${addStreamCard(tab.key)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -650,10 +695,30 @@ function renderPictureSourcesList(streams) {
|
||||
</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 {
|
||||
panelContent = `
|
||||
<div class="templates-grid">
|
||||
${tab.streams.map(renderStreamCard).join('')}
|
||||
${staticImageStreams.map(renderStreamCard).join('')}
|
||||
${addStreamCard(tab.key)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -77,6 +77,56 @@ function _createTargetFpsChart(canvasId, history, fpsTarget, maxHwFps) {
|
||||
// --- Segment editor state ---
|
||||
let _editorCssSources = []; // populated when editor opens
|
||||
|
||||
/**
|
||||
* When the selected CSS source is a mapped type, collapse the segment UI
|
||||
* to a single source dropdown — range fields, reverse, header, and "Add Segment"
|
||||
* are hidden because the mapped CSS already defines spatial zones internally.
|
||||
*/
|
||||
export function syncSegmentsMappedMode() {
|
||||
const list = document.getElementById('target-editor-segment-list');
|
||||
if (!list) return;
|
||||
const rows = list.querySelectorAll('.segment-row');
|
||||
if (rows.length === 0) return;
|
||||
|
||||
const firstSelect = rows[0].querySelector('.segment-css-select');
|
||||
const selectedId = firstSelect ? firstSelect.value : '';
|
||||
const selectedSource = _editorCssSources.find(s => s.id === selectedId);
|
||||
const isMapped = selectedSource && selectedSource.source_type === 'mapped';
|
||||
|
||||
// Remove extra segments when switching to mapped
|
||||
if (isMapped && rows.length > 1) {
|
||||
for (let i = rows.length - 1; i >= 1; i--) rows[i].remove();
|
||||
}
|
||||
|
||||
// Toggle visibility of range/reverse/header within the first row
|
||||
const firstRow = list.querySelector('.segment-row');
|
||||
if (firstRow) {
|
||||
const header = firstRow.querySelector('.segment-row-header');
|
||||
const rangeFields = firstRow.querySelector('.segment-range-fields');
|
||||
const reverseLabel = firstRow.querySelector('.segment-reverse-label');
|
||||
if (header) header.style.display = isMapped ? 'none' : '';
|
||||
if (rangeFields) rangeFields.style.display = isMapped ? 'none' : '';
|
||||
if (reverseLabel) reverseLabel.style.display = isMapped ? 'none' : '';
|
||||
}
|
||||
|
||||
// Hide/show "Add Segment" button
|
||||
const addBtn = document.querySelector('#target-editor-segments-group > .btn-sm');
|
||||
if (addBtn) addBtn.style.display = isMapped ? 'none' : '';
|
||||
|
||||
// Swap label: "Segments:" ↔ "Color Strip Source:"
|
||||
const group = document.getElementById('target-editor-segments-group');
|
||||
if (group) {
|
||||
const label = group.querySelector('.label-row label');
|
||||
const hintToggle = group.querySelector('.hint-toggle');
|
||||
const hint = group.querySelector('.input-hint');
|
||||
if (label) label.textContent = isMapped
|
||||
? t('targets.color_strip_source')
|
||||
: t('targets.segments');
|
||||
if (hintToggle) hintToggle.style.display = isMapped ? 'none' : '';
|
||||
if (hint) hint.style.display = 'none'; // collapse hint on switch
|
||||
}
|
||||
}
|
||||
|
||||
function _serializeSegments() {
|
||||
const rows = document.querySelectorAll('.segment-row');
|
||||
const segments = [];
|
||||
@@ -195,7 +245,7 @@ function _renderSegmentRowInner(index, segment) {
|
||||
<button type="button" class="btn-icon-inline btn-danger-text" onclick="removeTargetSegment(this)" title="${t('targets.segment.remove')}">×</button>
|
||||
</div>
|
||||
<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">
|
||||
<label>${t('targets.segment.start')}</label>
|
||||
<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();
|
||||
}
|
||||
|
||||
syncSegmentsMappedMode();
|
||||
|
||||
// Auto-name generation
|
||||
_targetNameManuallyEdited = !!(targetId || cloneData);
|
||||
document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; };
|
||||
|
||||
@@ -249,6 +249,7 @@
|
||||
"streams.description": "Sources define the capture pipeline. A raw source captures from a display using a capture template. A processed source applies postprocessing to another source. Assign sources to devices.",
|
||||
"streams.group.raw": "Screen Capture",
|
||||
"streams.group.processed": "Processed",
|
||||
"streams.group.audio": "Audio",
|
||||
"streams.section.streams": "\uD83D\uDCFA Sources",
|
||||
"streams.add": "Add Source",
|
||||
"streams.add.raw": "Add Screen Capture",
|
||||
@@ -365,6 +366,7 @@
|
||||
"targets.device.hint": "Select the LED device to send data to",
|
||||
"targets.device.none": "-- Select a device --",
|
||||
"targets.segments": "Segments:",
|
||||
"targets.color_strip_source": "Color Strip Source:",
|
||||
"targets.segments.hint": "Each segment maps a color strip source to a pixel range on the LED strip. Gaps between segments stay black. A single segment with Start=0, End=0 auto-fits to the full strip.",
|
||||
"targets.segments.add": "+ Add Segment",
|
||||
"targets.segment.start": "Start:",
|
||||
@@ -652,6 +654,8 @@
|
||||
"color_strip.type.effect.hint": "Procedural LED effects (fire, meteor, plasma, noise, aurora) generated in real time.",
|
||||
"color_strip.type.composite": "Composite",
|
||||
"color_strip.type.composite.hint": "Stack multiple color strip sources as layers with blend modes and opacity.",
|
||||
"color_strip.type.mapped": "Mapped",
|
||||
"color_strip.type.mapped.hint": "Assign different color strip sources to different LED ranges (zones). Unlike composite which blends layers, mapped places sources side-by-side.",
|
||||
"color_strip.type.audio": "Audio Reactive",
|
||||
"color_strip.type.audio.hint": "LED colors driven by real-time audio input — system audio or microphone.",
|
||||
"color_strip.composite.layers": "Layers:",
|
||||
@@ -668,18 +672,22 @@
|
||||
"color_strip.composite.error.min_layers": "At least 1 layer is required",
|
||||
"color_strip.composite.error.no_source": "Each layer must have a source selected",
|
||||
"color_strip.composite.layers_count": "layers",
|
||||
"color_strip.mapped.zones": "Zones:",
|
||||
"color_strip.mapped.zones.hint": "Each zone maps a color strip source to a specific LED range. Zones are placed side-by-side — gaps between zones stay black.",
|
||||
"color_strip.mapped.add_zone": "+ Add Zone",
|
||||
"color_strip.mapped.zone_source": "Source",
|
||||
"color_strip.mapped.zone_start": "Start LED",
|
||||
"color_strip.mapped.zone_end": "End LED",
|
||||
"color_strip.mapped.zone_reverse": "Reverse",
|
||||
"color_strip.mapped.zones_count": "zones",
|
||||
"color_strip.mapped.error.no_source": "Each zone must have a source selected",
|
||||
"color_strip.audio.visualization": "Visualization:",
|
||||
"color_strip.audio.visualization.hint": "How audio data is rendered to LEDs.",
|
||||
"color_strip.audio.viz.spectrum": "Spectrum Analyzer",
|
||||
"color_strip.audio.viz.beat_pulse": "Beat Pulse",
|
||||
"color_strip.audio.viz.vu_meter": "VU Meter",
|
||||
"color_strip.audio.device": "Audio Device:",
|
||||
"color_strip.audio.device.hint": "Audio input source. Loopback devices capture system audio output; input devices capture microphone or line-in.",
|
||||
"color_strip.audio.channel": "Channel:",
|
||||
"color_strip.audio.channel.hint": "Select which audio channel to visualize. Use Left/Right for stereo setups.",
|
||||
"color_strip.audio.channel.mono": "Mono (L+R mix)",
|
||||
"color_strip.audio.channel.left": "Left",
|
||||
"color_strip.audio.channel.right": "Right",
|
||||
"color_strip.audio.source": "Audio Source:",
|
||||
"color_strip.audio.source.hint": "Mono audio source that provides audio data for this visualization. Create and manage audio sources in the Sources tab.",
|
||||
"color_strip.audio.sensitivity": "Sensitivity:",
|
||||
"color_strip.audio.sensitivity.hint": "Gain multiplier for audio levels. Higher values make LEDs react to quieter sounds.",
|
||||
"color_strip.audio.smoothing": "Smoothing:",
|
||||
@@ -723,5 +731,39 @@
|
||||
"color_strip.palette.rainbow": "Rainbow",
|
||||
"color_strip.palette.aurora": "Aurora",
|
||||
"color_strip.palette.sunset": "Sunset",
|
||||
"color_strip.palette.ice": "Ice"
|
||||
"color_strip.palette.ice": "Ice",
|
||||
|
||||
"audio_source.title": "Audio Sources",
|
||||
"audio_source.group.multichannel": "Multichannel",
|
||||
"audio_source.group.mono": "Mono",
|
||||
"audio_source.add": "Add Audio Source",
|
||||
"audio_source.add.multichannel": "Add Multichannel Source",
|
||||
"audio_source.add.mono": "Add Mono Source",
|
||||
"audio_source.edit": "Edit Audio Source",
|
||||
"audio_source.edit.multichannel": "Edit Multichannel Source",
|
||||
"audio_source.edit.mono": "Edit Mono Source",
|
||||
"audio_source.name": "Name:",
|
||||
"audio_source.name.placeholder": "System Audio",
|
||||
"audio_source.name.hint": "A descriptive name for this audio source",
|
||||
"audio_source.type": "Type:",
|
||||
"audio_source.type.hint": "Multichannel captures all channels from a physical audio device. Mono extracts a single channel from a multichannel source.",
|
||||
"audio_source.type.multichannel": "Multichannel",
|
||||
"audio_source.type.mono": "Mono",
|
||||
"audio_source.device": "Audio Device:",
|
||||
"audio_source.device.hint": "Audio input source. Loopback devices capture system audio output; input devices capture microphone or line-in.",
|
||||
"audio_source.parent": "Parent Source:",
|
||||
"audio_source.parent.hint": "Multichannel source to extract a channel from",
|
||||
"audio_source.channel": "Channel:",
|
||||
"audio_source.channel.hint": "Which audio channel to extract from the multichannel source",
|
||||
"audio_source.channel.mono": "Mono (L+R mix)",
|
||||
"audio_source.channel.left": "Left",
|
||||
"audio_source.channel.right": "Right",
|
||||
"audio_source.description": "Description (optional):",
|
||||
"audio_source.description.placeholder": "Describe this audio source...",
|
||||
"audio_source.description.hint": "Optional notes about this audio source",
|
||||
"audio_source.created": "Audio source created",
|
||||
"audio_source.updated": "Audio source updated",
|
||||
"audio_source.deleted": "Audio source deleted",
|
||||
"audio_source.delete.confirm": "Are you sure you want to delete this audio source?",
|
||||
"audio_source.error.name_required": "Please enter a name"
|
||||
}
|
||||
|
||||
@@ -249,6 +249,7 @@
|
||||
"streams.description": "Источники определяют конвейер захвата. Сырой источник захватывает экран с помощью шаблона захвата. Обработанный источник применяет постобработку к другому источнику. Назначайте источники устройствам.",
|
||||
"streams.group.raw": "Захват Экрана",
|
||||
"streams.group.processed": "Обработанные",
|
||||
"streams.group.audio": "Аудио",
|
||||
"streams.section.streams": "\uD83D\uDCFA Источники",
|
||||
"streams.add": "Добавить Источник",
|
||||
"streams.add.raw": "Добавить Захват Экрана",
|
||||
@@ -365,6 +366,7 @@
|
||||
"targets.device.hint": "Выберите LED устройство для передачи данных",
|
||||
"targets.device.none": "-- Выберите устройство --",
|
||||
"targets.segments": "Сегменты:",
|
||||
"targets.color_strip_source": "Источник цветовой полосы:",
|
||||
"targets.segments.hint": "Каждый сегмент отображает источник цветовой полосы на диапазон пикселей LED ленты. Промежутки между сегментами остаются чёрными. Один сегмент с Начало=0, Конец=0 авто-подгоняется под всю ленту.",
|
||||
"targets.segments.add": "+ Добавить сегмент",
|
||||
"targets.segment.start": "Начало:",
|
||||
@@ -652,6 +654,8 @@
|
||||
"color_strip.type.effect.hint": "Процедурные LED-эффекты (огонь, метеор, плазма, шум, аврора), генерируемые в реальном времени.",
|
||||
"color_strip.type.composite": "Композит",
|
||||
"color_strip.type.composite.hint": "Наложение нескольких источников цветовой ленты как слоёв с режимами смешивания и прозрачностью.",
|
||||
"color_strip.type.mapped": "Маппинг",
|
||||
"color_strip.type.mapped.hint": "Назначает разные источники цветовой полосы на разные диапазоны LED (зоны). В отличие от композита, маппинг размещает источники рядом друг с другом.",
|
||||
"color_strip.type.audio": "Аудиореактив",
|
||||
"color_strip.type.audio.hint": "Цвета LED управляются аудиосигналом в реальном времени — системный звук или микрофон.",
|
||||
"color_strip.composite.layers": "Слои:",
|
||||
@@ -668,18 +672,22 @@
|
||||
"color_strip.composite.error.min_layers": "Необходим хотя бы 1 слой",
|
||||
"color_strip.composite.error.no_source": "Для каждого слоя должен быть выбран источник",
|
||||
"color_strip.composite.layers_count": "слоёв",
|
||||
"color_strip.mapped.zones": "Зоны:",
|
||||
"color_strip.mapped.zones.hint": "Каждая зона привязывает источник цветовой полосы к определённому диапазону LED. Зоны размещаются рядом — промежутки остаются чёрными.",
|
||||
"color_strip.mapped.add_zone": "+ Добавить зону",
|
||||
"color_strip.mapped.zone_source": "Источник",
|
||||
"color_strip.mapped.zone_start": "Начало LED",
|
||||
"color_strip.mapped.zone_end": "Конец LED",
|
||||
"color_strip.mapped.zone_reverse": "Реверс",
|
||||
"color_strip.mapped.zones_count": "зон",
|
||||
"color_strip.mapped.error.no_source": "Для каждой зоны должен быть выбран источник",
|
||||
"color_strip.audio.visualization": "Визуализация:",
|
||||
"color_strip.audio.visualization.hint": "Способ отображения аудиоданных на LED.",
|
||||
"color_strip.audio.viz.spectrum": "Анализатор спектра",
|
||||
"color_strip.audio.viz.beat_pulse": "Пульс бита",
|
||||
"color_strip.audio.viz.vu_meter": "VU-метр",
|
||||
"color_strip.audio.device": "Аудиоустройство:",
|
||||
"color_strip.audio.device.hint": "Источник аудиосигнала. Устройства обратной петли захватывают системный звук; устройства ввода — микрофон или линейный вход.",
|
||||
"color_strip.audio.channel": "Канал:",
|
||||
"color_strip.audio.channel.hint": "Какой аудиоканал визуализировать. Используйте Левый/Правый для стерео-режима.",
|
||||
"color_strip.audio.channel.mono": "Моно (Л+П микс)",
|
||||
"color_strip.audio.channel.left": "Левый",
|
||||
"color_strip.audio.channel.right": "Правый",
|
||||
"color_strip.audio.source": "Аудиоисточник:",
|
||||
"color_strip.audio.source.hint": "Моно-аудиоисточник, предоставляющий аудиоданные для визуализации. Создавайте и управляйте аудиоисточниками на вкладке Источники.",
|
||||
"color_strip.audio.sensitivity": "Чувствительность:",
|
||||
"color_strip.audio.sensitivity.hint": "Множитель усиления аудиосигнала. Более высокие значения делают LED чувствительнее к тихим звукам.",
|
||||
"color_strip.audio.smoothing": "Сглаживание:",
|
||||
@@ -723,5 +731,39 @@
|
||||
"color_strip.palette.rainbow": "Радуга",
|
||||
"color_strip.palette.aurora": "Аврора",
|
||||
"color_strip.palette.sunset": "Закат",
|
||||
"color_strip.palette.ice": "Лёд"
|
||||
"color_strip.palette.ice": "Лёд",
|
||||
|
||||
"audio_source.title": "Аудиоисточники",
|
||||
"audio_source.group.multichannel": "Многоканальные",
|
||||
"audio_source.group.mono": "Моно",
|
||||
"audio_source.add": "Добавить аудиоисточник",
|
||||
"audio_source.add.multichannel": "Добавить многоканальный",
|
||||
"audio_source.add.mono": "Добавить моно",
|
||||
"audio_source.edit": "Редактировать аудиоисточник",
|
||||
"audio_source.edit.multichannel": "Редактировать многоканальный",
|
||||
"audio_source.edit.mono": "Редактировать моно",
|
||||
"audio_source.name": "Название:",
|
||||
"audio_source.name.placeholder": "Системный звук",
|
||||
"audio_source.name.hint": "Описательное имя для этого аудиоисточника",
|
||||
"audio_source.type": "Тип:",
|
||||
"audio_source.type.hint": "Многоканальный захватывает все каналы с аудиоустройства. Моно извлекает один канал из многоканального источника.",
|
||||
"audio_source.type.multichannel": "Многоканальный",
|
||||
"audio_source.type.mono": "Моно",
|
||||
"audio_source.device": "Аудиоустройство:",
|
||||
"audio_source.device.hint": "Источник аудиосигнала. Устройства обратной петли захватывают системный звук; устройства ввода — микрофон или линейный вход.",
|
||||
"audio_source.parent": "Родительский источник:",
|
||||
"audio_source.parent.hint": "Многоканальный источник для извлечения канала",
|
||||
"audio_source.channel": "Канал:",
|
||||
"audio_source.channel.hint": "Какой аудиоканал извлечь из многоканального источника",
|
||||
"audio_source.channel.mono": "Моно (Л+П микс)",
|
||||
"audio_source.channel.left": "Левый",
|
||||
"audio_source.channel.right": "Правый",
|
||||
"audio_source.description": "Описание (необязательно):",
|
||||
"audio_source.description.placeholder": "Опишите этот аудиоисточник...",
|
||||
"audio_source.description.hint": "Необязательные заметки об этом аудиоисточнике",
|
||||
"audio_source.created": "Аудиоисточник создан",
|
||||
"audio_source.updated": "Аудиоисточник обновлён",
|
||||
"audio_source.deleted": "Аудиоисточник удалён",
|
||||
"audio_source.delete.confirm": "Удалить этот аудиоисточник?",
|
||||
"audio_source.error.name_required": "Введите название"
|
||||
}
|
||||
|
||||
113
server/src/wled_controller/storage/audio_source.py
Normal file
113
server/src/wled_controller/storage/audio_source.py
Normal 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
|
||||
324
server/src/wled_controller/storage/audio_source_store.py
Normal file
324
server/src/wled_controller/storage/audio_source_store.py
Normal 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")
|
||||
@@ -73,12 +73,11 @@ class ColorStripSource:
|
||||
"scale": None,
|
||||
"mirror": None,
|
||||
"layers": None,
|
||||
"zones": None,
|
||||
"visualization_mode": None,
|
||||
"audio_device_index": None,
|
||||
"audio_loopback": None,
|
||||
"audio_source_id": None,
|
||||
"sensitivity": None,
|
||||
"color_peak": None,
|
||||
"audio_channel": None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -155,6 +154,14 @@ class ColorStripSource:
|
||||
led_count=data.get("led_count") or 0,
|
||||
)
|
||||
|
||||
if source_type == "mapped":
|
||||
return MappedColorStripSource(
|
||||
id=sid, name=name, source_type="mapped",
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
zones=data.get("zones") or [],
|
||||
led_count=data.get("led_count") or 0,
|
||||
)
|
||||
|
||||
if source_type == "audio":
|
||||
raw_color = data.get("color")
|
||||
color = raw_color if isinstance(raw_color, list) and len(raw_color) == 3 else [0, 255, 0]
|
||||
@@ -164,9 +171,7 @@ class ColorStripSource:
|
||||
id=sid, name=name, source_type="audio",
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
visualization_mode=data.get("visualization_mode") or "spectrum",
|
||||
audio_device_index=int(data.get("audio_device_index", -1)),
|
||||
audio_loopback=bool(data.get("audio_loopback", True)),
|
||||
audio_channel=data.get("audio_channel") or "mono",
|
||||
audio_source_id=data.get("audio_source_id") or "",
|
||||
sensitivity=float(data.get("sensitivity") or 1.0),
|
||||
smoothing=float(data.get("smoothing") or 0.3),
|
||||
palette=data.get("palette") or "rainbow",
|
||||
@@ -366,9 +371,7 @@ class AudioColorStripSource(ColorStripSource):
|
||||
"""
|
||||
|
||||
visualization_mode: str = "spectrum" # spectrum | beat_pulse | vu_meter
|
||||
audio_device_index: int = -1 # -1 = default input device
|
||||
audio_loopback: bool = True # True = WASAPI loopback (system audio)
|
||||
audio_channel: str = "mono" # mono | left | right
|
||||
audio_source_id: str = "" # references a MonoAudioSource
|
||||
sensitivity: float = 1.0 # gain multiplier (0.1–5.0)
|
||||
smoothing: float = 0.3 # temporal smoothing (0.0–1.0)
|
||||
palette: str = "rainbow" # named color palette
|
||||
@@ -380,9 +383,7 @@ class AudioColorStripSource(ColorStripSource):
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
d["visualization_mode"] = self.visualization_mode
|
||||
d["audio_device_index"] = self.audio_device_index
|
||||
d["audio_loopback"] = self.audio_loopback
|
||||
d["audio_channel"] = self.audio_channel
|
||||
d["audio_source_id"] = self.audio_source_id
|
||||
d["sensitivity"] = self.sensitivity
|
||||
d["smoothing"] = self.smoothing
|
||||
d["palette"] = self.palette
|
||||
@@ -411,3 +412,24 @@ class CompositeColorStripSource(ColorStripSource):
|
||||
d["layers"] = [dict(layer) for layer in self.layers]
|
||||
d["led_count"] = self.led_count
|
||||
return d
|
||||
|
||||
|
||||
@dataclass
|
||||
class MappedColorStripSource(ColorStripSource):
|
||||
"""Color strip source that maps different sources to different LED ranges.
|
||||
|
||||
Each zone assigns a sub-range of LEDs to a different color strip source.
|
||||
Zones are placed side-by-side (spatial multiplexing) rather than blended.
|
||||
Gaps between zones stay black. LED count auto-sizes from the connected
|
||||
device when led_count == 0.
|
||||
"""
|
||||
|
||||
# Each zone: {"source_id": str, "start": int, "end": int, "reverse": bool}
|
||||
zones: list = field(default_factory=list)
|
||||
led_count: int = 0 # 0 = use device LED count
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
d["zones"] = [dict(z) for z in self.zones]
|
||||
d["led_count"] = self.led_count
|
||||
return d
|
||||
|
||||
@@ -14,6 +14,7 @@ from wled_controller.storage.color_strip_source import (
|
||||
CompositeColorStripSource,
|
||||
EffectColorStripSource,
|
||||
GradientColorStripSource,
|
||||
MappedColorStripSource,
|
||||
PictureColorStripSource,
|
||||
StaticColorStripSource,
|
||||
)
|
||||
@@ -119,10 +120,9 @@ class ColorStripStore:
|
||||
scale: float = 1.0,
|
||||
mirror: bool = False,
|
||||
layers: Optional[list] = None,
|
||||
zones: Optional[list] = None,
|
||||
visualization_mode: str = "spectrum",
|
||||
audio_device_index: int = -1,
|
||||
audio_loopback: bool = True,
|
||||
audio_channel: str = "mono",
|
||||
audio_source_id: str = "",
|
||||
sensitivity: float = 1.0,
|
||||
color_peak: Optional[list] = None,
|
||||
) -> ColorStripSource:
|
||||
@@ -214,9 +214,7 @@ class ColorStripStore:
|
||||
updated_at=now,
|
||||
description=description,
|
||||
visualization_mode=visualization_mode or "spectrum",
|
||||
audio_device_index=audio_device_index if audio_device_index is not None else -1,
|
||||
audio_loopback=bool(audio_loopback),
|
||||
audio_channel=audio_channel or "mono",
|
||||
audio_source_id=audio_source_id or "",
|
||||
sensitivity=float(sensitivity) if sensitivity else 1.0,
|
||||
smoothing=float(smoothing) if smoothing else 0.3,
|
||||
palette=palette or "rainbow",
|
||||
@@ -236,6 +234,17 @@ class ColorStripStore:
|
||||
layers=layers if isinstance(layers, list) else [],
|
||||
led_count=led_count,
|
||||
)
|
||||
elif source_type == "mapped":
|
||||
source = MappedColorStripSource(
|
||||
id=source_id,
|
||||
name=name,
|
||||
source_type="mapped",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
zones=zones if isinstance(zones, list) else [],
|
||||
led_count=led_count,
|
||||
)
|
||||
else:
|
||||
if calibration is None:
|
||||
calibration = CalibrationConfig(layout="clockwise", start_position="bottom_left")
|
||||
@@ -291,10 +300,9 @@ class ColorStripStore:
|
||||
scale: Optional[float] = None,
|
||||
mirror: Optional[bool] = None,
|
||||
layers: Optional[list] = None,
|
||||
zones: Optional[list] = None,
|
||||
visualization_mode: Optional[str] = None,
|
||||
audio_device_index: Optional[int] = None,
|
||||
audio_loopback: Optional[bool] = None,
|
||||
audio_channel: Optional[str] = None,
|
||||
audio_source_id: Optional[str] = None,
|
||||
sensitivity: Optional[float] = None,
|
||||
color_peak: Optional[list] = None,
|
||||
) -> ColorStripSource:
|
||||
@@ -380,12 +388,8 @@ class ColorStripStore:
|
||||
elif isinstance(source, AudioColorStripSource):
|
||||
if visualization_mode is not None:
|
||||
source.visualization_mode = visualization_mode
|
||||
if audio_device_index is not None:
|
||||
source.audio_device_index = audio_device_index
|
||||
if audio_loopback is not None:
|
||||
source.audio_loopback = bool(audio_loopback)
|
||||
if audio_channel is not None:
|
||||
source.audio_channel = audio_channel
|
||||
if audio_source_id is not None:
|
||||
source.audio_source_id = audio_source_id
|
||||
if sensitivity is not None:
|
||||
source.sensitivity = float(sensitivity)
|
||||
if smoothing is not None:
|
||||
@@ -405,6 +409,11 @@ class ColorStripStore:
|
||||
source.layers = layers
|
||||
if led_count is not None:
|
||||
source.led_count = led_count
|
||||
elif isinstance(source, MappedColorStripSource):
|
||||
if zones is not None and isinstance(zones, list):
|
||||
source.zones = zones
|
||||
if led_count is not None:
|
||||
source.led_count = led_count
|
||||
|
||||
source.updated_at = datetime.utcnow()
|
||||
self._save()
|
||||
@@ -436,3 +445,14 @@ class ColorStripStore:
|
||||
names.append(source.name)
|
||||
break
|
||||
return names
|
||||
|
||||
def get_mapped_referencing(self, source_id: str) -> List[str]:
|
||||
"""Return names of mapped sources that reference a given source as a zone."""
|
||||
names = []
|
||||
for source in self._sources.values():
|
||||
if isinstance(source, MappedColorStripSource):
|
||||
for zone in source.zones:
|
||||
if zone.get("source_id") == source_id:
|
||||
names.append(source.name)
|
||||
break
|
||||
return names
|
||||
|
||||
@@ -31,14 +31,14 @@
|
||||
<button class="theme-toggle" onclick="toggleTheme()" data-i18n-title="theme.toggle" title="Toggle theme">
|
||||
<span id="theme-icon">🌙</span>
|
||||
</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="ru">Русский</option>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
@@ -117,6 +117,7 @@
|
||||
{% include 'modals/stream.html' %}
|
||||
{% include 'modals/pp-template.html' %}
|
||||
{% include 'modals/profile-editor.html' %}
|
||||
{% include 'modals/audio-source-editor.html' %}
|
||||
|
||||
{% include 'partials/tutorial-overlay.html' %}
|
||||
{% include 'partials/image-lightbox.html' %}
|
||||
|
||||
@@ -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">×</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">× Cancel</button>
|
||||
<button class="btn btn-primary" onclick="saveAudioSource()" data-i18n="settings.button.save">✓ Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -27,6 +27,7 @@
|
||||
<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="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>
|
||||
</select>
|
||||
</div>
|
||||
@@ -316,6 +317,19 @@
|
||||
</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 -->
|
||||
<div id="css-editor-audio-section" style="display:none">
|
||||
<div class="form-group">
|
||||
@@ -333,25 +347,12 @@
|
||||
|
||||
<div class="form-group">
|
||||
<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>
|
||||
</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>
|
||||
<select id="css-editor-audio-device">
|
||||
<!-- populated dynamically from /api/v1/audio-devices -->
|
||||
</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>
|
||||
<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-source">
|
||||
<!-- populated dynamically from /api/v1/audio-sources?source_type=mono -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user