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")
|
||||
|
||||
Reference in New Issue
Block a user