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

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

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

View File

@@ -0,0 +1,53 @@
"""Audio source schemas (CRUD)."""
from datetime import datetime
from typing import List, Literal, Optional
from pydantic import BaseModel, Field
class AudioSourceCreate(BaseModel):
"""Request to create an audio source."""
name: str = Field(description="Source name", min_length=1, max_length=100)
source_type: Literal["multichannel", "mono"] = Field(description="Source type")
# multichannel fields
device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)")
is_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback)")
# mono fields
audio_source_id: Optional[str] = Field(None, description="Parent multichannel audio source ID")
channel: Optional[str] = Field(None, description="Channel: mono|left|right")
description: Optional[str] = Field(None, description="Optional description", max_length=500)
class AudioSourceUpdate(BaseModel):
"""Request to update an audio source."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)")
is_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback)")
audio_source_id: Optional[str] = Field(None, description="Parent multichannel audio source ID")
channel: Optional[str] = Field(None, description="Channel: mono|left|right")
description: Optional[str] = Field(None, description="Optional description", max_length=500)
class AudioSourceResponse(BaseModel):
"""Audio source response."""
id: str = Field(description="Source ID")
name: str = Field(description="Source name")
source_type: str = Field(description="Source type: multichannel or mono")
device_index: Optional[int] = Field(None, description="Audio device index")
is_loopback: Optional[bool] = Field(None, description="WASAPI loopback mode")
audio_source_id: Optional[str] = Field(None, description="Parent multichannel source ID")
channel: Optional[str] = Field(None, description="Channel: mono|left|right")
description: Optional[str] = Field(None, description="Description")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
class AudioSourceListResponse(BaseModel):
"""List of audio sources."""
sources: List[AudioSourceResponse] = Field(description="List of audio sources")
count: int = Field(description="Number of sources")

View File

@@ -36,11 +36,20 @@ class CompositeLayer(BaseModel):
enabled: bool = Field(default=True, description="Whether this layer is active")
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")