diff --git a/media_server/main.py b/media_server/main.py index 0c7b387..598d0a0 100644 --- a/media_server/main.py +++ b/media_server/main.py @@ -59,7 +59,7 @@ async def lifespan(app: FastAPI): await ws_manager.start_status_monitor(controller.get_status) logger.info("WebSocket status monitor started") - # Start audio visualizer (if enabled and dependencies available) + # Register audio visualizer (capture starts on-demand when clients subscribe) analyzer = None if settings.visualizer_enabled: from .services.audio_analyzer import get_audio_analyzer @@ -70,11 +70,8 @@ async def lifespan(app: FastAPI): device_name=settings.visualizer_device, ) if analyzer.available: - if analyzer.start(): - await ws_manager.start_audio_monitor(analyzer) - logger.info("Audio visualizer started") - else: - logger.warning("Audio visualizer failed to start (no loopback device?)") + await ws_manager.start_audio_monitor(analyzer) + logger.info("Audio visualizer available (capture on-demand)") else: logger.info("Audio visualizer unavailable (install soundcard + numpy)") diff --git a/media_server/routes/media.py b/media_server/routes/media.py index 0c2436a..971199c 100644 --- a/media_server/routes/media.py +++ b/media_server/routes/media.py @@ -305,15 +305,9 @@ async def set_visualizer_device( device_name = request.get("device_name") analyzer = get_audio_analyzer() - # Restart with new device - was_running = analyzer.running + # set_device() handles stop/start internally if capture was 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, diff --git a/media_server/services/audio_analyzer.py b/media_server/services/audio_analyzer.py index bc4ead5..f07f3cd 100644 --- a/media_server/services/audio_analyzer.py +++ b/media_server/services/audio_analyzer.py @@ -53,6 +53,7 @@ class AudioAnalyzer: self._running = False self._thread: threading.Thread | None = None self._lock = threading.Lock() + self._lifecycle_lock = threading.Lock() self._data: dict | None = None self._current_device_name: str | None = None @@ -88,24 +89,26 @@ class AudioAnalyzer: def start(self) -> bool: """Start audio capture in a background thread. Returns False if unavailable.""" - if self._running: - return True - if not self.available: - return False + with self._lifecycle_lock: + if self._running: + return True + if not self.available: + return False - self._running = True - self._thread = threading.Thread(target=self._capture_loop, daemon=True) - self._thread.start() - return True + self._running = True + self._thread = threading.Thread(target=self._capture_loop, daemon=True) + self._thread.start() + return True def stop(self) -> None: """Stop audio capture and cleanup.""" - self._running = False - if self._thread: - self._thread.join(timeout=3.0) - self._thread = None - with self._lock: - self._data = None + with self._lifecycle_lock: + self._running = False + if self._thread: + self._thread.join(timeout=3.0) + self._thread = None + with self._lock: + self._data = None def get_frequency_data(self) -> dict | None: """Return latest frequency data (thread-safe). None if not running.""" diff --git a/media_server/services/websocket_manager.py b/media_server/services/websocket_manager.py index a23a3dd..a36280f 100644 --- a/media_server/services/websocket_manager.py +++ b/media_server/services/websocket_manager.py @@ -46,10 +46,16 @@ class ConnectionManager: logger.debug("Failed to send initial status: %s", e) async def disconnect(self, websocket: WebSocket) -> None: - """Remove a WebSocket connection.""" + """Remove a WebSocket connection. Stops audio capture if last visualizer subscriber.""" + should_stop = False async with self._lock: self._active_connections.discard(websocket) + was_subscriber = websocket in self._visualizer_subscribers self._visualizer_subscribers.discard(websocket) + if was_subscriber and len(self._visualizer_subscribers) == 0: + should_stop = True + if should_stop: + await self._maybe_stop_capture() logger.info( "WebSocket client disconnected. Total: %d", len(self._active_connections) ) @@ -90,23 +96,50 @@ class ConnectionManager: logger.info("Broadcast sent: links_changed") async def subscribe_visualizer(self, websocket: WebSocket) -> None: - """Subscribe a client to audio visualizer data.""" + """Subscribe a client to audio visualizer data. Starts capture on first subscriber.""" + should_start = False async with self._lock: self._visualizer_subscribers.add(websocket) + if len(self._visualizer_subscribers) == 1 and self._audio_analyzer: + should_start = True + if should_start: + await self._maybe_start_capture() 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.""" + """Unsubscribe a client from audio visualizer data. Stops capture on last subscriber.""" + should_stop = False async with self._lock: self._visualizer_subscribers.discard(websocket) + if len(self._visualizer_subscribers) == 0: + should_stop = True + if should_stop: + await self._maybe_stop_capture() logger.debug("Visualizer subscriber removed. Total: %d", len(self._visualizer_subscribers)) + async def _maybe_start_capture(self) -> None: + """Start audio capture if not already running (called on first subscriber).""" + if self._audio_analyzer and not self._audio_analyzer.running: + loop = asyncio.get_event_loop() + started = await loop.run_in_executor(None, self._audio_analyzer.start) + if started: + logger.info("Audio capture started (first subscriber)") + else: + logger.warning("Audio capture failed to start") + + async def _maybe_stop_capture(self) -> None: + """Stop audio capture if running (called when last subscriber leaves).""" + if self._audio_analyzer and self._audio_analyzer.running: + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self._audio_analyzer.stop) + logger.info("Audio capture stopped (no subscribers)") + async def start_audio_monitor(self, analyzer) -> None: - """Start audio frequency broadcasting if analyzer is available.""" + """Register the audio analyzer. Capture starts on-demand when clients subscribe.""" self._audio_analyzer = analyzer - if analyzer and analyzer.running: + if analyzer and analyzer.available: self._audio_task = asyncio.create_task(self._audio_broadcast_loop()) - logger.info("Audio visualizer broadcast started") + logger.info("Audio visualizer broadcast loop started (capture on-demand)") async def stop_audio_monitor(self) -> None: """Stop audio frequency broadcasting.""" @@ -153,12 +186,8 @@ class ConnectionManager: 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) + for ws in failed: + await self.disconnect(ws) await asyncio.sleep(interval) diff --git a/media_server/static/css/styles.css b/media_server/static/css/styles.css index e42cf21..6dc878c 100644 --- a/media_server/static/css/styles.css +++ b/media_server/static/css/styles.css @@ -1010,7 +1010,7 @@ button:disabled { .scripts-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(min(180px, 100%), 1fr)); gap: 1rem; } @@ -1047,6 +1047,10 @@ button:disabled { .script-btn .script-label { font-weight: 600; font-size: 0.875rem; + text-align: center; + overflow-wrap: break-word; + word-break: break-word; + max-width: 100%; } .script-btn .script-description { @@ -1512,6 +1516,7 @@ button:disabled { border-radius: 4px; font-size: 0.75rem; color: var(--accent); + white-space: nowrap; } .action-btn {