Add audio capture engine template system with multi-backend support
Introduces an engine+template abstraction for audio capture, mirroring the existing screen capture engine pattern. This enables multiple audio backends (WASAPI for Windows, sounddevice for cross-platform) with per-source engine configuration via reusable templates. Backend: - AudioCaptureEngine ABC with WasapiEngine and SounddeviceEngine implementations - AudioEngineRegistry for engine discovery and factory creation - AudioAnalyzer class decouples FFT/RMS/beat analysis from engine-specific capture - ManagedAudioStream wraps engine stream + analyzer in background thread - AudioCaptureTemplate model and AudioTemplateStore with JSON CRUD - AudioCaptureManager keyed by (engine_type, device_index, is_loopback) - Auto-migration: default template created on startup, assigned to existing sources - Full REST API: CRUD for audio templates + engine listing with availability flags - audio_template_id added to MultichannelAudioSource model and API schemas Frontend: - Audio template cards in Streams > Audio tab with engine badge and config details - Audio template editor modal with engine selector and dynamic config fields - Audio template dropdown in multichannel audio source editor - Template name crosslink badge on multichannel audio source cards - Confirm modal z-index fix (always stacks above editor modals) - i18n keys for EN and RU Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,8 @@ from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.audio.audio_capture import AudioCaptureManager, NUM_BANDS
|
||||
from wled_controller.core.audio.analysis import NUM_BANDS
|
||||
from wled_controller.core.audio.audio_capture import AudioCaptureManager
|
||||
from wled_controller.core.processing.color_strip_stream import ColorStripStream
|
||||
from wled_controller.core.processing.effect_stream import _build_palette_lut
|
||||
from wled_controller.utils import get_logger
|
||||
@@ -35,9 +36,10 @@ class AudioColorStripStream(ColorStripStream):
|
||||
thread, double-buffered output, configure() for auto-sizing.
|
||||
"""
|
||||
|
||||
def __init__(self, source, audio_capture_manager: AudioCaptureManager, audio_source_store=None):
|
||||
def __init__(self, source, audio_capture_manager: AudioCaptureManager, audio_source_store=None, audio_template_store=None):
|
||||
self._audio_capture_manager = audio_capture_manager
|
||||
self._audio_source_store = audio_source_store
|
||||
self._audio_template_store = audio_template_store
|
||||
self._audio_stream = None # acquired on start
|
||||
|
||||
self._colors_lock = threading.Lock()
|
||||
@@ -74,15 +76,26 @@ class AudioColorStripStream(ColorStripStream):
|
||||
self._led_count = source.led_count if source.led_count and source.led_count > 0 else 1
|
||||
self._mirror = bool(getattr(source, "mirror", False))
|
||||
|
||||
# Resolve audio device/channel via audio_source_id
|
||||
# Resolve audio device/channel/template via audio_source_id
|
||||
audio_source_id = getattr(source, "audio_source_id", "")
|
||||
self._audio_source_id = audio_source_id
|
||||
self._audio_engine_type = None
|
||||
self._audio_engine_config = None
|
||||
if audio_source_id and self._audio_source_store:
|
||||
try:
|
||||
device_index, is_loopback, channel = self._audio_source_store.resolve_audio_source(audio_source_id)
|
||||
device_index, is_loopback, channel, template_id = (
|
||||
self._audio_source_store.resolve_audio_source(audio_source_id)
|
||||
)
|
||||
self._audio_device_index = device_index
|
||||
self._audio_loopback = is_loopback
|
||||
self._audio_channel = channel
|
||||
if template_id and self._audio_template_store:
|
||||
try:
|
||||
tpl = self._audio_template_store.get_template(template_id)
|
||||
self._audio_engine_type = tpl.engine_type
|
||||
self._audio_engine_config = tpl.engine_config
|
||||
except ValueError:
|
||||
pass
|
||||
except ValueError as e:
|
||||
logger.warning(f"Failed to resolve audio source {audio_source_id}: {e}")
|
||||
self._audio_device_index = -1
|
||||
@@ -121,7 +134,9 @@ class AudioColorStripStream(ColorStripStream):
|
||||
return
|
||||
# Acquire shared audio capture stream
|
||||
self._audio_stream = self._audio_capture_manager.acquire(
|
||||
self._audio_device_index, self._audio_loopback
|
||||
self._audio_device_index, self._audio_loopback,
|
||||
engine_type=self._audio_engine_type,
|
||||
engine_config=self._audio_engine_config,
|
||||
)
|
||||
self._running = True
|
||||
self._thread = threading.Thread(
|
||||
@@ -132,6 +147,7 @@ class AudioColorStripStream(ColorStripStream):
|
||||
self._thread.start()
|
||||
logger.info(
|
||||
f"AudioColorStripStream started (viz={self._visualization_mode}, "
|
||||
f"engine={self._audio_engine_type}, "
|
||||
f"device={self._audio_device_index}, loopback={self._audio_loopback})"
|
||||
)
|
||||
|
||||
@@ -144,7 +160,10 @@ class AudioColorStripStream(ColorStripStream):
|
||||
self._thread = None
|
||||
# Release shared audio capture
|
||||
if self._audio_stream is not None:
|
||||
self._audio_capture_manager.release(self._audio_device_index, self._audio_loopback)
|
||||
self._audio_capture_manager.release(
|
||||
self._audio_device_index, self._audio_loopback,
|
||||
engine_type=self._audio_engine_type,
|
||||
)
|
||||
self._audio_stream = None
|
||||
self._prev_spectrum = None
|
||||
logger.info("AudioColorStripStream stopped")
|
||||
@@ -161,20 +180,31 @@ class AudioColorStripStream(ColorStripStream):
|
||||
if isinstance(source, AudioColorStripSource):
|
||||
old_device = self._audio_device_index
|
||||
old_loopback = self._audio_loopback
|
||||
old_engine_type = self._audio_engine_type
|
||||
prev_led_count = self._led_count if self._auto_size else None
|
||||
self._update_from_source(source)
|
||||
if prev_led_count and self._auto_size:
|
||||
self._led_count = prev_led_count
|
||||
|
||||
# If audio device changed, swap capture stream
|
||||
if self._running and (self._audio_device_index != old_device or self._audio_loopback != old_loopback):
|
||||
self._audio_capture_manager.release(old_device, old_loopback)
|
||||
# If audio device or engine changed, swap capture stream
|
||||
needs_swap = (
|
||||
self._audio_device_index != old_device
|
||||
or self._audio_loopback != old_loopback
|
||||
or self._audio_engine_type != old_engine_type
|
||||
)
|
||||
if self._running and needs_swap:
|
||||
self._audio_capture_manager.release(
|
||||
old_device, old_loopback, engine_type=old_engine_type,
|
||||
)
|
||||
self._audio_stream = self._audio_capture_manager.acquire(
|
||||
self._audio_device_index, self._audio_loopback
|
||||
self._audio_device_index, self._audio_loopback,
|
||||
engine_type=self._audio_engine_type,
|
||||
engine_config=self._audio_engine_config,
|
||||
)
|
||||
logger.info(
|
||||
f"AudioColorStripStream swapped audio device: "
|
||||
f"{old_device}:{old_loopback} → {self._audio_device_index}:{self._audio_loopback}"
|
||||
f"{old_engine_type}:{old_device}:{old_loopback} → "
|
||||
f"{self._audio_engine_type}:{self._audio_device_index}:{self._audio_loopback}"
|
||||
)
|
||||
|
||||
logger.info("AudioColorStripStream params updated in-place")
|
||||
|
||||
@@ -58,7 +58,7 @@ class ColorStripStreamManager:
|
||||
keyed by ``{css_id}:{consumer_id}``.
|
||||
"""
|
||||
|
||||
def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None, audio_source_store=None):
|
||||
def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None, audio_source_store=None, audio_template_store=None):
|
||||
"""
|
||||
Args:
|
||||
color_strip_store: ColorStripStore for resolving source configs
|
||||
@@ -70,6 +70,7 @@ class ColorStripStreamManager:
|
||||
self._live_stream_manager = live_stream_manager
|
||||
self._audio_capture_manager = audio_capture_manager
|
||||
self._audio_source_store = audio_source_store
|
||||
self._audio_template_store = audio_template_store
|
||||
self._streams: Dict[str, _ColorStripEntry] = {}
|
||||
|
||||
def _resolve_key(self, css_id: str, consumer_id: str) -> str:
|
||||
@@ -108,7 +109,7 @@ 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, self._audio_source_store)
|
||||
css_stream = AudioColorStripStream(source, self._audio_capture_manager, self._audio_source_store, self._audio_template_store)
|
||||
elif source.source_type == "composite":
|
||||
from wled_controller.core.processing.composite_stream import CompositeColorStripStream
|
||||
css_stream = CompositeColorStripStream(source, self)
|
||||
|
||||
@@ -66,7 +66,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, audio_source_store=None, value_source_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, value_source_store=None, audio_template_store=None):
|
||||
"""Initialize processor manager."""
|
||||
self._devices: Dict[str, DeviceState] = {}
|
||||
self._processors: Dict[str, TargetProcessor] = {}
|
||||
@@ -80,6 +80,7 @@ class ProcessorManager:
|
||||
self._device_store = device_store
|
||||
self._color_strip_store = color_strip_store
|
||||
self._audio_source_store = audio_source_store
|
||||
self._audio_template_store = audio_template_store
|
||||
self._value_source_store = value_source_store
|
||||
self._live_stream_manager = LiveStreamManager(
|
||||
picture_source_store, capture_template_store, pp_template_store
|
||||
@@ -90,12 +91,14 @@ class ProcessorManager:
|
||||
live_stream_manager=self._live_stream_manager,
|
||||
audio_capture_manager=self._audio_capture_manager,
|
||||
audio_source_store=audio_source_store,
|
||||
audio_template_store=audio_template_store,
|
||||
)
|
||||
self._value_stream_manager = ValueStreamManager(
|
||||
value_source_store=value_source_store,
|
||||
audio_capture_manager=self._audio_capture_manager,
|
||||
audio_source_store=audio_source_store,
|
||||
live_stream_manager=self._live_stream_manager,
|
||||
audio_template_store=audio_template_store,
|
||||
) if value_source_store else None
|
||||
self._overlay_manager = OverlayManager()
|
||||
self._event_queues: List[asyncio.Queue] = []
|
||||
|
||||
@@ -160,6 +160,7 @@ class AudioValueStream(ValueStream):
|
||||
max_value: float = 1.0,
|
||||
audio_capture_manager: Optional["AudioCaptureManager"] = None,
|
||||
audio_source_store: Optional["AudioSourceStore"] = None,
|
||||
audio_template_store=None,
|
||||
):
|
||||
self._audio_source_id = audio_source_id
|
||||
self._mode = mode
|
||||
@@ -169,11 +170,14 @@ class AudioValueStream(ValueStream):
|
||||
self._max = max_value
|
||||
self._audio_capture_manager = audio_capture_manager
|
||||
self._audio_source_store = audio_source_store
|
||||
self._audio_template_store = audio_template_store
|
||||
|
||||
# Resolved audio device params
|
||||
self._audio_device_index = -1
|
||||
self._audio_loopback = True
|
||||
self._audio_channel = "mono"
|
||||
self._audio_engine_type = None
|
||||
self._audio_engine_config = None
|
||||
|
||||
self._audio_stream = None
|
||||
self._prev_value = 0.0
|
||||
@@ -182,15 +186,22 @@ class AudioValueStream(ValueStream):
|
||||
self._resolve_audio_source()
|
||||
|
||||
def _resolve_audio_source(self) -> None:
|
||||
"""Resolve audio source (mono or multichannel) to device index / channel."""
|
||||
"""Resolve audio source to device index / channel / engine info."""
|
||||
if self._audio_source_id and self._audio_source_store:
|
||||
try:
|
||||
device_index, is_loopback, channel = (
|
||||
device_index, is_loopback, channel, template_id = (
|
||||
self._audio_source_store.resolve_audio_source(self._audio_source_id)
|
||||
)
|
||||
self._audio_device_index = device_index
|
||||
self._audio_loopback = is_loopback
|
||||
self._audio_channel = channel
|
||||
if template_id and self._audio_template_store:
|
||||
try:
|
||||
tpl = self._audio_template_store.get_template(template_id)
|
||||
self._audio_engine_type = tpl.engine_type
|
||||
self._audio_engine_config = tpl.engine_config
|
||||
except ValueError:
|
||||
pass
|
||||
except ValueError as e:
|
||||
logger.warning(f"Failed to resolve audio source {self._audio_source_id}: {e}")
|
||||
|
||||
@@ -198,16 +209,21 @@ class AudioValueStream(ValueStream):
|
||||
if self._audio_capture_manager is None:
|
||||
return
|
||||
self._audio_stream = self._audio_capture_manager.acquire(
|
||||
self._audio_device_index, self._audio_loopback
|
||||
self._audio_device_index, self._audio_loopback,
|
||||
engine_type=self._audio_engine_type,
|
||||
engine_config=self._audio_engine_config,
|
||||
)
|
||||
logger.info(
|
||||
f"AudioValueStream started (mode={self._mode}, "
|
||||
f"AudioValueStream started (mode={self._mode}, engine={self._audio_engine_type}, "
|
||||
f"device={self._audio_device_index}, loopback={self._audio_loopback})"
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._audio_stream is not None and self._audio_capture_manager is not None:
|
||||
self._audio_capture_manager.release(self._audio_device_index, self._audio_loopback)
|
||||
self._audio_capture_manager.release(
|
||||
self._audio_device_index, self._audio_loopback,
|
||||
engine_type=self._audio_engine_type,
|
||||
)
|
||||
self._audio_stream = None
|
||||
self._prev_value = 0.0
|
||||
self._beat_brightness = 0.0
|
||||
@@ -279,16 +295,21 @@ class AudioValueStream(ValueStream):
|
||||
if source.audio_source_id != old_source_id:
|
||||
old_device = self._audio_device_index
|
||||
old_loopback = self._audio_loopback
|
||||
old_engine_type = self._audio_engine_type
|
||||
self._resolve_audio_source()
|
||||
if self._audio_stream is not None and self._audio_capture_manager is not None:
|
||||
self._audio_capture_manager.release(old_device, old_loopback)
|
||||
self._audio_capture_manager.release(
|
||||
old_device, old_loopback, engine_type=old_engine_type,
|
||||
)
|
||||
self._audio_stream = self._audio_capture_manager.acquire(
|
||||
self._audio_device_index, self._audio_loopback
|
||||
self._audio_device_index, self._audio_loopback,
|
||||
engine_type=self._audio_engine_type,
|
||||
engine_config=self._audio_engine_config,
|
||||
)
|
||||
logger.info(
|
||||
f"AudioValueStream swapped audio device: "
|
||||
f"{old_device}:{old_loopback} → "
|
||||
f"{self._audio_device_index}:{self._audio_loopback}"
|
||||
f"{old_engine_type}:{old_device}:{old_loopback} → "
|
||||
f"{self._audio_engine_type}:{self._audio_device_index}:{self._audio_loopback}"
|
||||
)
|
||||
|
||||
|
||||
@@ -521,11 +542,13 @@ class ValueStreamManager:
|
||||
audio_capture_manager: Optional["AudioCaptureManager"] = None,
|
||||
audio_source_store: Optional["AudioSourceStore"] = None,
|
||||
live_stream_manager: Optional["LiveStreamManager"] = None,
|
||||
audio_template_store=None,
|
||||
):
|
||||
self._value_source_store = value_source_store
|
||||
self._audio_capture_manager = audio_capture_manager
|
||||
self._audio_source_store = audio_source_store
|
||||
self._live_stream_manager = live_stream_manager
|
||||
self._audio_template_store = audio_template_store
|
||||
self._streams: Dict[str, ValueStream] = {}
|
||||
|
||||
def acquire(self, vs_id: str, consumer_id: str) -> ValueStream:
|
||||
@@ -611,6 +634,7 @@ class ValueStreamManager:
|
||||
max_value=source.max_value,
|
||||
audio_capture_manager=self._audio_capture_manager,
|
||||
audio_source_store=self._audio_source_store,
|
||||
audio_template_store=self._audio_template_store,
|
||||
)
|
||||
|
||||
if isinstance(source, AdaptiveValueSource):
|
||||
|
||||
Reference in New Issue
Block a user