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:
2026-02-26 13:55:46 +03:00
parent cbbaa852ed
commit bae2166bc2
35 changed files with 2163 additions and 402 deletions

View File

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

View File

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

View File

@@ -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] = []

View File

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