On-demand audio visualizer capture + UI fixes
- Audio capture starts only when first client subscribes, stops when last client unsubscribes (saves CPU/battery) - Add lifecycle lock to AudioAnalyzer for thread-safe start/stop - Status badge uses local visualizer state instead of server flag - Fix script name vertical text break on narrow screens - Fix script grid minimum column width on small viewports Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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?)")
|
||||
logger.info("Audio visualizer available (capture on-demand)")
|
||||
else:
|
||||
logger.info("Audio visualizer unavailable (install soundcard + numpy)")
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,6 +89,7 @@ class AudioAnalyzer:
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start audio capture in a background thread. Returns False if unavailable."""
|
||||
with self._lifecycle_lock:
|
||||
if self._running:
|
||||
return True
|
||||
if not self.available:
|
||||
@@ -100,6 +102,7 @@ class AudioAnalyzer:
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop audio capture and cleanup."""
|
||||
with self._lifecycle_lock:
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=3.0)
|
||||
|
||||
@@ -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,10 +186,6 @@ 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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user