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:
2026-02-27 21:42:19 +03:00
parent 8a8f00ff31
commit 0691e3d338
11 changed files with 919 additions and 2 deletions

View File

@@ -268,6 +268,59 @@ async def get_artwork(_: str = Depends(verify_token_or_query)) -> Response:
return Response(content=art_bytes, media_type=content_type)
@router.get("/visualizer/status")
async def visualizer_status(_: str = Depends(verify_token)) -> dict:
"""Check if audio visualizer is available and running."""
from ..services.audio_analyzer import get_audio_analyzer
analyzer = get_audio_analyzer()
return {
"available": analyzer.available,
"running": analyzer.running,
"current_device": analyzer.current_device,
}
@router.get("/visualizer/devices")
async def visualizer_devices(_: str = Depends(verify_token)) -> list[dict[str, str]]:
"""List available loopback audio devices for the visualizer."""
from ..services.audio_analyzer import AudioAnalyzer
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, AudioAnalyzer.list_loopback_devices)
@router.post("/visualizer/device")
async def set_visualizer_device(
request: dict,
_: str = Depends(verify_token),
) -> dict:
"""Set the loopback audio device for the visualizer.
Body: {"device_name": "Device Name" | null}
Passing null resets to auto-detect.
"""
from ..services.audio_analyzer import get_audio_analyzer
device_name = request.get("device_name")
analyzer = get_audio_analyzer()
# Restart with new device
was_running = analyzer.running
success = analyzer.set_device(device_name)
# Restart audio broadcast if needed
if was_running and success and analyzer.running:
await ws_manager.stop_audio_monitor()
await ws_manager.start_audio_monitor(analyzer)
return {
"success": success,
"current_device": analyzer.current_device,
"running": analyzer.running,
}
@router.websocket("/ws")
async def websocket_endpoint(
websocket: WebSocket,
@@ -321,6 +374,10 @@ async def websocket_endpoint(
if volume is not None:
controller = get_media_controller()
await controller.set_volume(int(volume))
elif data.get("type") == "enable_visualizer":
await ws_manager.subscribe_visualizer(websocket)
elif data.get("type") == "disable_visualizer":
await ws_manager.unsubscribe_visualizer(websocket)
except WebSocketDisconnect:
await ws_manager.disconnect(websocket)