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

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

View File

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

View File

@@ -0,0 +1,212 @@
"""Mapped color strip stream — places different sources at different LED ranges."""
import threading
import time
from typing import Dict, List, Optional
import numpy as np
from wled_controller.core.processing.color_strip_stream import ColorStripStream
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class MappedColorStripStream(ColorStripStream):
"""Places multiple ColorStripStreams side-by-side at distinct LED ranges.
Each zone references an existing (non-mapped) ColorStripSource and is
assigned a start/end LED range. Unlike composite (which blends layers
covering ALL LEDs), mapped assigns each source to a distinct sub-range.
Gaps between zones stay black (zeros).
Processing runs in a background thread at 30 FPS, polling each
sub-stream's latest colors and copying into the output array.
"""
def __init__(self, source, css_manager):
self._source_id: str = source.id
self._zones: List[dict] = list(source.zones)
self._led_count: int = source.led_count
self._auto_size: bool = source.led_count == 0
self._css_manager = css_manager
self._fps: int = 30
self._running = False
self._thread: Optional[threading.Thread] = None
self._latest_colors: Optional[np.ndarray] = None
self._colors_lock = threading.Lock()
# zone_index -> (source_id, consumer_id, stream)
self._sub_streams: Dict[int, tuple] = {}
# ── ColorStripStream interface ──────────────────────────────
@property
def target_fps(self) -> int:
return self._fps
@property
def led_count(self) -> int:
return self._led_count
@property
def is_animated(self) -> bool:
return True
def start(self) -> None:
self._acquire_sub_streams()
self._running = True
self._thread = threading.Thread(
target=self._processing_loop, daemon=True,
name=f"MappedCSS-{self._source_id[:12]}",
)
self._thread.start()
logger.info(
f"MappedColorStripStream started: {self._source_id} "
f"({len(self._sub_streams)} zones, {self._led_count} LEDs)"
)
def stop(self) -> None:
self._running = False
if self._thread is not None:
self._thread.join(timeout=5.0)
self._thread = None
self._release_sub_streams()
logger.info(f"MappedColorStripStream stopped: {self._source_id}")
def get_latest_colors(self) -> Optional[np.ndarray]:
with self._colors_lock:
return self._latest_colors
def configure(self, device_led_count: int) -> None:
if self._auto_size and device_led_count > 0 and device_led_count != self._led_count:
self._led_count = device_led_count
self._reconfigure_sub_streams()
logger.debug(f"MappedColorStripStream auto-sized to {device_led_count} LEDs")
def update_source(self, source) -> None:
"""Hot-update: rebuild sub-streams if zone config changed."""
new_zones = list(source.zones)
old_zone_ids = [(z.get("source_id"), z.get("start"), z.get("end"), z.get("reverse"))
for z in self._zones]
new_zone_ids = [(z.get("source_id"), z.get("start"), z.get("end"), z.get("reverse"))
for z in new_zones]
self._zones = new_zones
if source.led_count != 0:
self._led_count = source.led_count
self._auto_size = False
if old_zone_ids != new_zone_ids:
self._release_sub_streams()
self._acquire_sub_streams()
logger.info(f"MappedColorStripStream rebuilt sub-streams: {self._source_id}")
# ── Sub-stream lifecycle ────────────────────────────────────
def _acquire_sub_streams(self) -> None:
for i, zone in enumerate(self._zones):
src_id = zone.get("source_id", "")
if not src_id:
continue
consumer_id = f"{self._source_id}__zone_{i}"
try:
stream = self._css_manager.acquire(src_id, consumer_id)
zone_len = self._zone_length(zone)
if hasattr(stream, "configure") and zone_len > 0:
stream.configure(zone_len)
self._sub_streams[i] = (src_id, consumer_id, stream)
except Exception as e:
logger.warning(
f"Mapped zone {i} (source {src_id}) failed to acquire: {e}"
)
def _release_sub_streams(self) -> None:
for _idx, (src_id, consumer_id, _stream) in list(self._sub_streams.items()):
try:
self._css_manager.release(src_id, consumer_id)
except Exception as e:
logger.warning(f"Mapped zone release error ({src_id}): {e}")
self._sub_streams.clear()
def _reconfigure_sub_streams(self) -> None:
"""Reconfigure zone sub-streams with updated LED ranges."""
for i, zone in enumerate(self._zones):
if i not in self._sub_streams:
continue
_src_id, _consumer_id, stream = self._sub_streams[i]
zone_len = self._zone_length(zone)
if hasattr(stream, "configure") and zone_len > 0:
stream.configure(zone_len)
def _zone_length(self, zone: dict) -> int:
"""Calculate LED count for a zone. end=0 means auto-fill to total."""
start = zone.get("start", 0)
end = zone.get("end", 0)
if end <= 0:
end = self._led_count
return max(0, end - start)
# ── Processing loop ─────────────────────────────────────────
def _processing_loop(self) -> None:
while self._running:
loop_start = time.perf_counter()
frame_time = 1.0 / self._fps
try:
target_n = self._led_count
if target_n <= 0:
time.sleep(frame_time)
continue
result = np.zeros((target_n, 3), dtype=np.uint8)
for i, zone in enumerate(self._zones):
if i not in self._sub_streams:
continue
_src_id, _consumer_id, stream = self._sub_streams[i]
colors = stream.get_latest_colors()
if colors is None:
continue
start = zone.get("start", 0)
end = zone.get("end", 0)
if end <= 0:
end = target_n
start = max(0, min(start, target_n))
end = max(start, min(end, target_n))
zone_len = end - start
if zone_len <= 0:
continue
# Resize sub-stream output to zone length if needed
if len(colors) != zone_len:
src_x = np.linspace(0, 1, len(colors))
dst_x = np.linspace(0, 1, zone_len)
resized = np.empty((zone_len, 3), dtype=np.uint8)
for ch in range(3):
np.copyto(
resized[:, ch],
np.interp(dst_x, src_x, colors[:, ch]),
casting="unsafe",
)
colors = resized
if zone.get("reverse", False):
colors = colors[::-1]
result[start:end] = colors
with self._colors_lock:
self._latest_colors = result
except Exception as e:
logger.error(f"MappedColorStripStream processing error: {e}", exc_info=True)
elapsed = time.perf_counter() - loop_start
time.sleep(max(frame_time - elapsed, 0.001))

View File

@@ -64,7 +64,7 @@ class ProcessorManager:
Targets are registered for processing via polymorphic TargetProcessor subclasses.
"""
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] = []