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:
@@ -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] = []
|
||||
|
||||
Reference in New Issue
Block a user