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

@@ -12,6 +12,7 @@ from .routes.picture_targets import router as picture_targets_router
from .routes.color_strip_sources import router as color_strip_sources_router
from .routes.audio import router as audio_router
from .routes.audio_sources import router as audio_sources_router
from .routes.audio_templates import router as audio_templates_router
from .routes.value_sources import router as value_sources_router
from .routes.profiles import router as profiles_router
@@ -25,6 +26,7 @@ router.include_router(picture_sources_router)
router.include_router(color_strip_sources_router)
router.include_router(audio_router)
router.include_router(audio_sources_router)
router.include_router(audio_templates_router)
router.include_router(value_sources_router)
router.include_router(picture_targets_router)
router.include_router(profiles_router)

View File

@@ -9,6 +9,7 @@ from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.picture_target_store import PictureTargetStore
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.audio_source_store import AudioSourceStore
from wled_controller.storage.audio_template_store import AudioTemplateStore
from wled_controller.storage.value_source_store import ValueSourceStore
from wled_controller.storage.profile_store import ProfileStore
from wled_controller.core.profiles.profile_engine import ProfileEngine
@@ -22,6 +23,7 @@ _picture_source_store: PictureSourceStore | None = None
_picture_target_store: PictureTargetStore | None = None
_color_strip_store: ColorStripStore | None = None
_audio_source_store: AudioSourceStore | None = None
_audio_template_store: AudioTemplateStore | None = None
_value_source_store: ValueSourceStore | None = None
_processor_manager: ProcessorManager | None = None
_profile_store: ProfileStore | None = None
@@ -84,6 +86,13 @@ def get_audio_source_store() -> AudioSourceStore:
return _audio_source_store
def get_audio_template_store() -> AudioTemplateStore:
"""Get audio template store dependency."""
if _audio_template_store is None:
raise RuntimeError("Audio template store not initialized")
return _audio_template_store
def get_value_source_store() -> ValueSourceStore:
"""Get value source store dependency."""
if _value_source_store is None:
@@ -122,6 +131,7 @@ def init_dependencies(
picture_target_store: PictureTargetStore | None = None,
color_strip_store: ColorStripStore | None = None,
audio_source_store: AudioSourceStore | None = None,
audio_template_store: AudioTemplateStore | None = None,
value_source_store: ValueSourceStore | None = None,
profile_store: ProfileStore | None = None,
profile_engine: ProfileEngine | None = None,
@@ -129,7 +139,8 @@ def init_dependencies(
"""Initialize global dependencies."""
global _device_store, _template_store, _processor_manager
global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_store
global _color_strip_store, _audio_source_store, _value_source_store, _profile_store, _profile_engine
global _color_strip_store, _audio_source_store, _audio_template_store
global _value_source_store, _profile_store, _profile_engine
_device_store = device_store
_template_store = template_store
_processor_manager = processor_manager
@@ -139,6 +150,7 @@ def init_dependencies(
_picture_target_store = picture_target_store
_color_strip_store = color_strip_store
_audio_source_store = audio_source_store
_audio_template_store = audio_template_store
_value_source_store = value_source_store
_profile_store = profile_store
_profile_engine = profile_engine

View File

@@ -33,6 +33,7 @@ def _to_response(source: AudioSource) -> AudioSourceResponse:
source_type=source.source_type,
device_index=getattr(source, "device_index", None),
is_loopback=getattr(source, "is_loopback", None),
audio_template_id=getattr(source, "audio_template_id", None),
audio_source_id=getattr(source, "audio_source_id", None),
channel=getattr(source, "channel", None),
description=source.description,
@@ -73,6 +74,7 @@ async def create_audio_source(
audio_source_id=data.audio_source_id,
channel=data.channel,
description=data.description,
audio_template_id=data.audio_template_id,
)
return _to_response(source)
except ValueError as e:
@@ -110,6 +112,7 @@ async def update_audio_source(
audio_source_id=data.audio_source_id,
channel=data.channel,
description=data.description,
audio_template_id=data.audio_template_id,
)
return _to_response(source)
except ValueError as e:

View File

@@ -0,0 +1,159 @@
"""Audio capture template and engine routes."""
from fastapi import APIRouter, HTTPException, Depends
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import get_audio_template_store, get_audio_source_store
from wled_controller.api.schemas.audio_templates import (
AudioEngineInfo,
AudioEngineListResponse,
AudioTemplateCreate,
AudioTemplateListResponse,
AudioTemplateResponse,
AudioTemplateUpdate,
)
from wled_controller.core.audio.factory import AudioEngineRegistry
from wled_controller.storage.audio_template_store import AudioTemplateStore
from wled_controller.storage.audio_source_store import AudioSourceStore
from wled_controller.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
# ===== AUDIO TEMPLATE ENDPOINTS =====
@router.get("/api/v1/audio-templates", response_model=AudioTemplateListResponse, tags=["Audio Templates"])
async def list_audio_templates(
_auth: AuthRequired,
store: AudioTemplateStore = Depends(get_audio_template_store),
):
"""List all audio capture templates."""
try:
templates = store.get_all_templates()
responses = [
AudioTemplateResponse(
id=t.id, name=t.name, engine_type=t.engine_type,
engine_config=t.engine_config, created_at=t.created_at,
updated_at=t.updated_at, description=t.description,
)
for t in templates
]
return AudioTemplateListResponse(templates=responses, count=len(responses))
except Exception as e:
logger.error(f"Failed to list audio templates: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/v1/audio-templates", response_model=AudioTemplateResponse, tags=["Audio Templates"], status_code=201)
async def create_audio_template(
data: AudioTemplateCreate,
_auth: AuthRequired,
store: AudioTemplateStore = Depends(get_audio_template_store),
):
"""Create a new audio capture template."""
try:
template = store.create_template(
name=data.name, engine_type=data.engine_type,
engine_config=data.engine_config, description=data.description,
)
return AudioTemplateResponse(
id=template.id, name=template.name, engine_type=template.engine_type,
engine_config=template.engine_config, created_at=template.created_at,
updated_at=template.updated_at, description=template.description,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to create audio template: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/v1/audio-templates/{template_id}", response_model=AudioTemplateResponse, tags=["Audio Templates"])
async def get_audio_template(
template_id: str,
_auth: AuthRequired,
store: AudioTemplateStore = Depends(get_audio_template_store),
):
"""Get audio template by ID."""
try:
t = store.get_template(template_id)
except ValueError:
raise HTTPException(status_code=404, detail=f"Audio template {template_id} not found")
return AudioTemplateResponse(
id=t.id, name=t.name, engine_type=t.engine_type,
engine_config=t.engine_config, created_at=t.created_at,
updated_at=t.updated_at, description=t.description,
)
@router.put("/api/v1/audio-templates/{template_id}", response_model=AudioTemplateResponse, tags=["Audio Templates"])
async def update_audio_template(
template_id: str,
data: AudioTemplateUpdate,
_auth: AuthRequired,
store: AudioTemplateStore = Depends(get_audio_template_store),
):
"""Update an audio template."""
try:
t = store.update_template(
template_id=template_id, name=data.name,
engine_type=data.engine_type, engine_config=data.engine_config,
description=data.description,
)
return AudioTemplateResponse(
id=t.id, name=t.name, engine_type=t.engine_type,
engine_config=t.engine_config, created_at=t.created_at,
updated_at=t.updated_at, description=t.description,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to update audio template: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/api/v1/audio-templates/{template_id}", status_code=204, tags=["Audio Templates"])
async def delete_audio_template(
template_id: str,
_auth: AuthRequired,
store: AudioTemplateStore = Depends(get_audio_template_store),
audio_source_store: AudioSourceStore = Depends(get_audio_source_store),
):
"""Delete an audio template."""
try:
store.delete_template(template_id, audio_source_store=audio_source_store)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to delete audio template: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ===== AUDIO ENGINE ENDPOINTS =====
@router.get("/api/v1/audio-engines", response_model=AudioEngineListResponse, tags=["Audio Templates"])
async def list_audio_engines(_auth: AuthRequired):
"""List all registered audio capture engines."""
try:
available_set = set(AudioEngineRegistry.get_available_engines())
all_engines = AudioEngineRegistry.get_all_engines()
engines = []
for engine_type, engine_class in all_engines.items():
engines.append(
AudioEngineInfo(
type=engine_type,
name=engine_type.upper(),
default_config=engine_class.get_default_config(),
available=(engine_type in available_set),
)
)
return AudioEngineListResponse(engines=engines, count=len(engines))
except Exception as e:
logger.error(f"Failed to list audio engines: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -14,6 +14,7 @@ class AudioSourceCreate(BaseModel):
# 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)")
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
# 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")
@@ -26,6 +27,7 @@ class AudioSourceUpdate(BaseModel):
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_template_id: Optional[str] = Field(None, description="Audio capture template ID")
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)
@@ -39,6 +41,7 @@ class AudioSourceResponse(BaseModel):
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_template_id: Optional[str] = Field(None, description="Audio capture template ID")
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")

View File

@@ -0,0 +1,59 @@
"""Audio capture template and engine schemas."""
from datetime import datetime
from typing import Dict, List, Optional
from pydantic import BaseModel, Field
class AudioTemplateCreate(BaseModel):
"""Request to create an audio capture template."""
name: str = Field(description="Template name", min_length=1, max_length=100)
engine_type: str = Field(description="Audio engine type (e.g., 'wasapi', 'sounddevice')", min_length=1)
engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500)
class AudioTemplateUpdate(BaseModel):
"""Request to update an audio template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
engine_type: Optional[str] = Field(None, description="Audio engine type")
engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500)
class AudioTemplateResponse(BaseModel):
"""Audio template information response."""
id: str = Field(description="Template ID")
name: str = Field(description="Template name")
engine_type: str = Field(description="Engine type identifier")
engine_config: Dict = Field(description="Engine-specific configuration")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description")
class AudioTemplateListResponse(BaseModel):
"""List of audio templates response."""
templates: List[AudioTemplateResponse] = Field(description="List of audio templates")
count: int = Field(description="Number of templates")
class AudioEngineInfo(BaseModel):
"""Audio capture engine information."""
type: str = Field(description="Engine type identifier (e.g., 'wasapi', 'sounddevice')")
name: str = Field(description="Human-readable engine name")
default_config: Dict = Field(description="Default configuration for this engine")
available: bool = Field(description="Whether engine is available on this system")
class AudioEngineListResponse(BaseModel):
"""List of audio engines response."""
engines: List[AudioEngineInfo] = Field(description="Available audio engines")
count: int = Field(description="Number of engines")