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:
@@ -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:
|
||||
|
||||
159
server/src/wled_controller/api/routes/audio_templates.py
Normal file
159
server/src/wled_controller/api/routes/audio_templates.py
Normal 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))
|
||||
Reference in New Issue
Block a user