Performance (hot path): - Fix double brightness: removed duplicate scaling from 9 device clients (wled, adalight, ambiled, openrgb, hue, spi, chroma, gamesense, usbhid, espnow) — processor loop is now the single source of brightness - Bounded send_timestamps deque with maxlen, removed 3 cleanup loops - Running FPS sum O(1) instead of sum()/len() O(n) per frame - datetime.now(timezone.utc) → time.monotonic() with lazy conversion - Device info refresh interval 30 → 300 iterations - Composite: gate layer_snapshots copy on preview client flag - Composite: versioned sub_streams snapshot (copy only on change) - Composite: pre-resolved blend methods (dict lookup vs getattr) - ApiInput: np.copyto in-place instead of astype allocation Code quality: - BaseJsonStore: RLock on get/delete/get_all/count (was created but unused) - EntityNotFoundError → proper 404 responses across 15 route files - Remove 21 defensive getattr(x,'tags',[]) — field guaranteed on all models - Fix Dict[str,any] → Dict[str,Any] in template/audio_template stores - Log 4 silenced exceptions (automation engine, metrics, system) - ValueStream.get_value() now @abstractmethod - Config.from_yaml: add encoding="utf-8" - OutputTargetStore: remove 25-line _load override, use _legacy_json_keys - BaseJsonStore: add _legacy_json_keys for migration support - Remove unnecessary except Exception→500 from postprocessing list endpoint Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
252 lines
8.8 KiB
Python
252 lines
8.8 KiB
Python
"""Audio source routes: CRUD for audio sources + real-time test WebSocket."""
|
|
|
|
import asyncio
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from starlette.websockets import WebSocket, WebSocketDisconnect
|
|
|
|
from wled_controller.api.auth import AuthRequired
|
|
from wled_controller.api.dependencies import (
|
|
fire_entity_event,
|
|
get_audio_source_store,
|
|
get_audio_template_store,
|
|
get_color_strip_store,
|
|
get_processor_manager,
|
|
)
|
|
from wled_controller.api.schemas.audio_sources import (
|
|
AudioSourceCreate,
|
|
AudioSourceListResponse,
|
|
AudioSourceResponse,
|
|
AudioSourceUpdate,
|
|
)
|
|
from wled_controller.storage.audio_source import AudioSource
|
|
from wled_controller.storage.audio_source_store import AudioSourceStore
|
|
from wled_controller.storage.color_strip_store import ColorStripStore
|
|
from wled_controller.utils import get_logger
|
|
from wled_controller.storage.base_store import EntityNotFoundError
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _to_response(source: AudioSource) -> AudioSourceResponse:
|
|
"""Convert an AudioSource to an AudioSourceResponse."""
|
|
return AudioSourceResponse(
|
|
id=source.id,
|
|
name=source.name,
|
|
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,
|
|
tags=source.tags,
|
|
created_at=source.created_at,
|
|
updated_at=source.updated_at,
|
|
)
|
|
|
|
|
|
@router.get("/api/v1/audio-sources", response_model=AudioSourceListResponse, tags=["Audio Sources"])
|
|
async def list_audio_sources(
|
|
_auth: AuthRequired,
|
|
source_type: Optional[str] = Query(None, description="Filter by source_type: multichannel or mono"),
|
|
store: AudioSourceStore = Depends(get_audio_source_store),
|
|
):
|
|
"""List all audio sources, optionally filtered by type."""
|
|
sources = store.get_all_sources()
|
|
if source_type:
|
|
sources = [s for s in sources if s.source_type == source_type]
|
|
return AudioSourceListResponse(
|
|
sources=[_to_response(s) for s in sources],
|
|
count=len(sources),
|
|
)
|
|
|
|
|
|
@router.post("/api/v1/audio-sources", response_model=AudioSourceResponse, status_code=201, tags=["Audio Sources"])
|
|
async def create_audio_source(
|
|
data: AudioSourceCreate,
|
|
_auth: AuthRequired,
|
|
store: AudioSourceStore = Depends(get_audio_source_store),
|
|
):
|
|
"""Create a new audio source."""
|
|
try:
|
|
source = store.create_source(
|
|
name=data.name,
|
|
source_type=data.source_type,
|
|
device_index=data.device_index,
|
|
is_loopback=data.is_loopback,
|
|
audio_source_id=data.audio_source_id,
|
|
channel=data.channel,
|
|
description=data.description,
|
|
audio_template_id=data.audio_template_id,
|
|
tags=data.tags,
|
|
)
|
|
fire_entity_event("audio_source", "created", source.id)
|
|
return _to_response(source)
|
|
except EntityNotFoundError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
@router.get("/api/v1/audio-sources/{source_id}", response_model=AudioSourceResponse, tags=["Audio Sources"])
|
|
async def get_audio_source(
|
|
source_id: str,
|
|
_auth: AuthRequired,
|
|
store: AudioSourceStore = Depends(get_audio_source_store),
|
|
):
|
|
"""Get an audio source by ID."""
|
|
try:
|
|
source = store.get_source(source_id)
|
|
return _to_response(source)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
|
|
@router.put("/api/v1/audio-sources/{source_id}", response_model=AudioSourceResponse, tags=["Audio Sources"])
|
|
async def update_audio_source(
|
|
source_id: str,
|
|
data: AudioSourceUpdate,
|
|
_auth: AuthRequired,
|
|
store: AudioSourceStore = Depends(get_audio_source_store),
|
|
):
|
|
"""Update an existing audio source."""
|
|
try:
|
|
source = store.update_source(
|
|
source_id=source_id,
|
|
name=data.name,
|
|
device_index=data.device_index,
|
|
is_loopback=data.is_loopback,
|
|
audio_source_id=data.audio_source_id,
|
|
channel=data.channel,
|
|
description=data.description,
|
|
audio_template_id=data.audio_template_id,
|
|
tags=data.tags,
|
|
)
|
|
fire_entity_event("audio_source", "updated", source_id)
|
|
return _to_response(source)
|
|
except EntityNotFoundError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
@router.delete("/api/v1/audio-sources/{source_id}", status_code=204, tags=["Audio Sources"])
|
|
async def delete_audio_source(
|
|
source_id: str,
|
|
_auth: AuthRequired,
|
|
store: AudioSourceStore = Depends(get_audio_source_store),
|
|
css_store: ColorStripStore = Depends(get_color_strip_store),
|
|
):
|
|
"""Delete an audio source."""
|
|
try:
|
|
# Check if any CSS entities reference this audio source
|
|
from wled_controller.storage.color_strip_source import AudioColorStripSource
|
|
for css in css_store.get_all_sources():
|
|
if isinstance(css, AudioColorStripSource) and getattr(css, "audio_source_id", None) == source_id:
|
|
raise ValueError(
|
|
f"Cannot delete: referenced by color strip source '{css.name}'"
|
|
)
|
|
|
|
store.delete_source(source_id)
|
|
fire_entity_event("audio_source", "deleted", source_id)
|
|
except EntityNotFoundError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
# ===== REAL-TIME AUDIO TEST WEBSOCKET =====
|
|
|
|
|
|
@router.websocket("/api/v1/audio-sources/{source_id}/test/ws")
|
|
async def test_audio_source_ws(
|
|
websocket: WebSocket,
|
|
source_id: str,
|
|
token: str = Query(""),
|
|
):
|
|
"""WebSocket for real-time audio spectrum analysis. Auth via ?token=<api_key>.
|
|
|
|
Resolves the audio source to its device, acquires a ManagedAudioStream
|
|
(ref-counted — shares with running targets), and streams AudioAnalysis
|
|
snapshots as JSON at ~20 Hz.
|
|
"""
|
|
from wled_controller.api.auth import verify_ws_token
|
|
if not verify_ws_token(token):
|
|
await websocket.close(code=4001, reason="Unauthorized")
|
|
return
|
|
|
|
# Resolve source → device info
|
|
store = get_audio_source_store()
|
|
template_store = get_audio_template_store()
|
|
manager = get_processor_manager()
|
|
|
|
try:
|
|
device_index, is_loopback, channel, audio_template_id = store.resolve_audio_source(source_id)
|
|
except ValueError as e:
|
|
await websocket.close(code=4004, reason=str(e))
|
|
return
|
|
|
|
# Resolve template → engine_type + config
|
|
engine_type = None
|
|
engine_config = None
|
|
if audio_template_id:
|
|
try:
|
|
template = template_store.get_template(audio_template_id)
|
|
engine_type = template.engine_type
|
|
engine_config = template.engine_config
|
|
except ValueError:
|
|
pass # Fall back to best available engine
|
|
|
|
# Acquire shared audio stream
|
|
audio_mgr = manager.audio_capture_manager
|
|
try:
|
|
stream = audio_mgr.acquire(device_index, is_loopback, engine_type, engine_config)
|
|
except RuntimeError as e:
|
|
await websocket.close(code=4003, reason=str(e))
|
|
return
|
|
|
|
await websocket.accept()
|
|
logger.info(f"Audio test WebSocket connected for source {source_id}")
|
|
|
|
last_ts = 0.0
|
|
try:
|
|
while True:
|
|
analysis = stream.get_latest_analysis()
|
|
if analysis is not None and analysis.timestamp != last_ts:
|
|
last_ts = analysis.timestamp
|
|
|
|
# Select channel-specific data
|
|
if channel == "left":
|
|
spectrum = analysis.left_spectrum
|
|
rms = analysis.left_rms
|
|
elif channel == "right":
|
|
spectrum = analysis.right_spectrum
|
|
rms = analysis.right_rms
|
|
else:
|
|
spectrum = analysis.spectrum
|
|
rms = analysis.rms
|
|
|
|
await websocket.send_json({
|
|
"spectrum": spectrum.tolist(),
|
|
"rms": round(rms, 4),
|
|
"peak": round(analysis.peak, 4),
|
|
"beat": analysis.beat,
|
|
"beat_intensity": round(analysis.beat_intensity, 4),
|
|
})
|
|
|
|
await asyncio.sleep(0.05)
|
|
except WebSocketDisconnect:
|
|
pass
|
|
except Exception as e:
|
|
logger.error(f"Audio test WebSocket error for {source_id}: {e}")
|
|
finally:
|
|
audio_mgr.release(device_index, is_loopback, engine_type)
|
|
logger.info(f"Audio test WebSocket disconnected for source {source_id}")
|