Add audio visualizer with spectrogram, beat-reactive art, and device selection
- New audio_analyzer service: loopback capture via soundcard + numpy FFT - Real-time spectrogram bars below album art with accent color gradient - Album art and vinyl pulse to bass energy beats - WebSocket subscriber pattern for opt-in audio data streaming - Audio device selection in Settings tab with auto-detect fallback - Optimized FFT pipeline: vectorized cumsum bin grouping, pre-serialized JSON broadcast - Visualizer config: enabled/fps/bins/device in config.yaml - Optional deps: soundcard + numpy (graceful degradation if missing) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""WebSocket connection manager and status broadcaster."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Callable, Coroutine
|
||||
@@ -23,6 +24,10 @@ class ConnectionManager:
|
||||
self._position_broadcast_interval: float = 5.0 # Send position updates every 5s during playback
|
||||
self._last_broadcast_time: float = 0.0
|
||||
self._running: bool = False
|
||||
# Audio visualizer
|
||||
self._visualizer_subscribers: set[WebSocket] = set()
|
||||
self._audio_task: asyncio.Task | None = None
|
||||
self._audio_analyzer = None
|
||||
|
||||
async def connect(self, websocket: WebSocket) -> None:
|
||||
"""Accept a new WebSocket connection."""
|
||||
@@ -44,6 +49,7 @@ class ConnectionManager:
|
||||
"""Remove a WebSocket connection."""
|
||||
async with self._lock:
|
||||
self._active_connections.discard(websocket)
|
||||
self._visualizer_subscribers.discard(websocket)
|
||||
logger.info(
|
||||
"WebSocket client disconnected. Total: %d", len(self._active_connections)
|
||||
)
|
||||
@@ -83,6 +89,85 @@ class ConnectionManager:
|
||||
await self.broadcast(message)
|
||||
logger.info("Broadcast sent: links_changed")
|
||||
|
||||
async def subscribe_visualizer(self, websocket: WebSocket) -> None:
|
||||
"""Subscribe a client to audio visualizer data."""
|
||||
async with self._lock:
|
||||
self._visualizer_subscribers.add(websocket)
|
||||
logger.debug("Visualizer subscriber added. Total: %d", len(self._visualizer_subscribers))
|
||||
|
||||
async def unsubscribe_visualizer(self, websocket: WebSocket) -> None:
|
||||
"""Unsubscribe a client from audio visualizer data."""
|
||||
async with self._lock:
|
||||
self._visualizer_subscribers.discard(websocket)
|
||||
logger.debug("Visualizer subscriber removed. Total: %d", len(self._visualizer_subscribers))
|
||||
|
||||
async def start_audio_monitor(self, analyzer) -> None:
|
||||
"""Start audio frequency broadcasting if analyzer is available."""
|
||||
self._audio_analyzer = analyzer
|
||||
if analyzer and analyzer.running:
|
||||
self._audio_task = asyncio.create_task(self._audio_broadcast_loop())
|
||||
logger.info("Audio visualizer broadcast started")
|
||||
|
||||
async def stop_audio_monitor(self) -> None:
|
||||
"""Stop audio frequency broadcasting."""
|
||||
if self._audio_task:
|
||||
self._audio_task.cancel()
|
||||
try:
|
||||
await self._audio_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._audio_task = None
|
||||
|
||||
async def _audio_broadcast_loop(self) -> None:
|
||||
"""Background loop: read frequency data from analyzer and broadcast to subscribers."""
|
||||
from ..config import settings
|
||||
interval = 1.0 / settings.visualizer_fps
|
||||
|
||||
_last_data = None
|
||||
|
||||
while True:
|
||||
try:
|
||||
async with self._lock:
|
||||
subscribers = list(self._visualizer_subscribers)
|
||||
|
||||
if not subscribers or not self._audio_analyzer or not self._audio_analyzer.running:
|
||||
await asyncio.sleep(interval)
|
||||
continue
|
||||
|
||||
data = self._audio_analyzer.get_frequency_data()
|
||||
if data is None or data is _last_data:
|
||||
await asyncio.sleep(interval)
|
||||
continue
|
||||
_last_data = data
|
||||
|
||||
# Pre-serialize once for all subscribers (avoids per-client JSON encoding)
|
||||
text = json.dumps({"type": "audio_data", "data": data}, separators=(',', ':'))
|
||||
|
||||
async def _send(ws: WebSocket) -> WebSocket | None:
|
||||
try:
|
||||
await ws.send_text(text)
|
||||
return None
|
||||
except Exception:
|
||||
return ws
|
||||
|
||||
results = await asyncio.gather(*(_send(ws) for ws in subscribers))
|
||||
|
||||
failed = [ws for ws in results if ws is not None]
|
||||
if failed:
|
||||
async with self._lock:
|
||||
for ws in failed:
|
||||
self._visualizer_subscribers.discard(ws)
|
||||
for ws in failed:
|
||||
await self.disconnect(ws)
|
||||
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Error in audio broadcast: %s", e)
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
def status_changed(
|
||||
self, old: dict[str, Any] | None, new: dict[str, Any]
|
||||
) -> bool:
|
||||
|
||||
Reference in New Issue
Block a user