From bb9156bedef41adb60fdde45b98fb24cdeaf9ce3 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Wed, 11 Mar 2026 10:39:46 +0100 Subject: [PATCH 01/14] WIP this needs more work --- README.md | 18 ++- sendspin/audio_connector.py | 2 +- sendspin/cli.py | 19 +++ sendspin/daemon/daemon.py | 121 ++++++++++++++++++- sendspin/decoder.py | 4 +- sendspin/serve/server.py | 4 +- sendspin/settings.py | 8 ++ sendspin/tui/app.py | 79 ++++++++++++- sendspin/tui/keyboard.py | 14 +++ sendspin/tui/ui.py | 83 +++++++++++++ sendspin/tui/visualizer.py | 197 +++++++++++++++++++++++++++++++ sendspin/visualizer_connector.py | 147 +++++++++++++++++++++++ 12 files changed, 686 insertions(+), 10 deletions(-) create mode 100644 sendspin/tui/visualizer.py create mode 100644 sendspin/visualizer_connector.py diff --git a/README.md b/README.md index 4998ce1..1a07bca 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,8 @@ Settings are stored in `~/.config/sendspin/`: "listen_port": 8927, "use_mpris": true, "use_hardware_volume": true, - "hook_set_volume": "/usr/local/bin/set-avr-volume" + "hook_set_volume": "/usr/local/bin/set-avr-volume", + "visualizer_enabled": true } ``` @@ -170,6 +171,7 @@ Settings are stored in `~/.config/sendspin/`: | `hook_set_volume` | string | TUI/daemon | Script to run for external volume control (`--hook-set-volume`). Receives the effective volume 0-100 as the last argument | | `hook_start` | string | TUI/daemon | Command to run when audio stream starts | | `hook_stop` | string | TUI/daemon | Command to run when audio stream stops | +| `visualizer_enabled` | boolean | TUI | Enable audio visualizer (default: true) | | `source` | string | serve | Default audio source (file path or URL, ffmpeg input) | | `source_format` | string | serve | ffmpeg container format for audio source | | `clients` | array | serve | Client URLs to connect to (`--client`) | @@ -311,6 +313,20 @@ Hooks receive these environment variables: - `SENDSPIN_CLIENT_ID` - Client identifier - `SENDSPIN_CLIENT_NAME` - Client friendly name +### Visualizer + +The TUI includes a real-time audio spectrum visualizer that displays frequency data received from the server. The visualizer is enabled by default. + +**Toggle the visualizer:** + +Press `v` during playback to toggle the visualizer on or off. The setting is persisted. + +**Disable the visualizer on startup:** + +```bash +sendspin --no-visualizer +``` + ### Debugging & Troubleshooting If you experience synchronization issues or audio glitches, you can enable detailed logging to help diagnose the problem: diff --git a/sendspin/audio_connector.py b/sendspin/audio_connector.py index 6d7bd5f..f364090 100644 --- a/sendspin/audio_connector.py +++ b/sendspin/audio_connector.py @@ -462,7 +462,7 @@ def _on_audio_chunk( worker.submit_chunk(server_timestamp_us, audio_data, fmt) - def _on_stream_start(self, _message: StreamStartMessage) -> None: + def _on_stream_start(self, message: StreamStartMessage) -> None: """Handle stream start by clearing stale audio chunks.""" assert self._client is not None, "Received stream start but client is not attached" if self._audio_worker is None or not self._audio_worker.is_running(): diff --git a/sendspin/cli.py b/sendspin/cli.py index 97ffcfd..a9fb285 100644 --- a/sendspin/cli.py +++ b/sendspin/cli.py @@ -172,6 +172,18 @@ def _add_player_runtime_options(target: ArgumentTarget, *, suppress_defaults: bo default=default, help="Command to run when audio stream stops (receives SENDSPIN_* env vars)", ) + target.add_argument( + "--no-visualizer", + action="store_true", + default=argparse.SUPPRESS if suppress_defaults else False, + help="Disable the audio visualizer in the TUI", + ) + target.add_argument( + "--no-visualizer-smoothing", + action="store_true", + default=argparse.SUPPRESS if suppress_defaults else False, + help="Disable visualizer interpolation/smoothing in the TUI", + ) def _add_player_actions(target: ArgumentTarget, *, suppress_defaults: bool = False) -> None: @@ -694,6 +706,11 @@ async def _run_client_mode(args: argparse.Namespace) -> int: f"Hardware volume control is not available on this system. " f"{HW_VOLUME_UNAVAILABLE_REASON or 'Use --hardware-volume false to disable.'}" ) + # Keep visualizer enabled by default unless explicitly disabled via CLI flag. + args.visualizer_enabled = not getattr(args, "no_visualizer", False) + args.visualizer_smoothing_enabled = settings.visualizer_smoothing_enabled and not getattr( + args, "no_visualizer_smoothing", False + ) if args.hook_start is None: args.hook_start = settings.hook_start if args.hook_stop is None: @@ -761,6 +778,8 @@ async def _run_client_mode(args: argparse.Namespace) -> int: volume_controller=volume_controller, hook_start=args.hook_start, hook_stop=args.hook_stop, + visualizer_enabled=args.visualizer_enabled, + visualizer_smoothing_enabled=args.visualizer_smoothing_enabled, ) app = SendspinApp(app_args) diff --git a/sendspin/daemon/daemon.py b/sendspin/daemon/daemon.py index 2176883..522a278 100644 --- a/sendspin/daemon/daemon.py +++ b/sendspin/daemon/daemon.py @@ -6,8 +6,9 @@ import contextlib import logging import signal -from dataclasses import dataclass -from typing import TYPE_CHECKING +import time +from dataclasses import dataclass, fields as dataclass_fields +from typing import TYPE_CHECKING, Any from aiohttp import ClientError, web from aiosendspin.client import ClientListener, SendspinClient @@ -15,8 +16,16 @@ ClientGoodbyeMessage, ClientGoodbyePayload, ServerCommandPayload, + StreamStartMessage, ) from aiosendspin.models.player import ClientHelloPlayerSupport, SupportedAudioFormat +from aiosendspin.models.visualizer import ClientHelloVisualizerSupport + +try: + from aiosendspin.models.visualizer import ClientHelloVisualizerSpectrum, VisualizerFrame +except ImportError: + ClientHelloVisualizerSpectrum = None + VisualizerFrame = Any from aiosendspin_mpris import MPRIS_AVAILABLE, SendspinMpris from aiosendspin.models.types import ( GoodbyeReason, @@ -29,6 +38,7 @@ from sendspin.hooks import run_hook from sendspin.settings import ClientSettings from sendspin.utils import create_task, get_device_info +from sendspin.visualizer_connector import VisualizerHandler if TYPE_CHECKING: from sendspin.volume_controller import VolumeController @@ -73,11 +83,16 @@ def __init__(self, args: DaemonArgs) -> None: self._static_delay_ms: float = 0.0 self._connection_lock: asyncio.Lock | None = None self._server_url: str | None = None + self._visualizer_handler: VisualizerHandler | None = None + self._visualizer_batch_count: int = 0 def _create_client(self, static_delay_ms: float = 0.0) -> SendspinClient: """Create a new SendspinClient instance.""" assert self._audio_handler is not None client_roles = [Roles.PLAYER] + visualizer_support = self._build_visualizer_support() + if visualizer_support is not None: + client_roles.append(Roles.VISUALIZER) if MPRIS_AVAILABLE and self._args.use_mpris: client_roles.extend([Roles.METADATA, Roles.CONTROLLER]) @@ -96,11 +111,108 @@ def _create_client(self, static_delay_ms: float = 0.0) -> SendspinClient: buffer_capacity=32_000_000, supported_commands=[PlayerCommand.VOLUME, PlayerCommand.MUTE], ), + visualizer_support=visualizer_support, static_delay_ms=static_delay_ms, initial_volume=self._audio_handler.volume, initial_muted=self._audio_handler.muted, ) + @staticmethod + def _build_visualizer_support() -> ClientHelloVisualizerSupport | None: + """Build draft-r1 visualizer support payload for daemon mode.""" + support_fields = {f.name for f in dataclass_fields(ClientHelloVisualizerSupport)} + if not {"types", "batch_max", "spectrum"}.issubset(support_fields): + logger.warning( + "Installed aiosendspin visualizer support model is outdated; " + "daemon will continue without visualizer role" + ) + return None + return ClientHelloVisualizerSupport( + buffer_capacity=65536, + types=["loudness", "spectrum"], + batch_max=8, + spectrum=ClientHelloVisualizerSpectrum( + n_disp_bins=48, + scale="mel", + f_min=20, + f_max=20000, + rate_max=30, + ), + ) + + def _attach_visualizer_debug(self, client: SendspinClient) -> None: + """Attach visualizer debug callbacks for daemon mode.""" + if not hasattr(client, "add_visualizer_listener"): + logger.warning( + "Installed aiosendspin client lacks visualizer listener API; " + "visualizer debug disabled" + ) + return + # Raw binary tap: log every visualization binary payload before parsing. + raw_handler = getattr(client, "_handle_visualization_data", None) + if callable(raw_handler): + + def _wrapped_visualization_data(payload: bytes) -> None: + logger.info( + "Visualizer raw binary received: bytes=%d first8=%s", + len(payload), + payload[:8].hex(), + ) + raw_handler(payload) + + setattr(client, "_handle_visualization_data", _wrapped_visualization_data) + logger.info("Visualizer raw binary tap attached") + + self._visualizer_batch_count = 0 + client.add_visualizer_listener(self._on_visualizer_batch) + client.add_stream_start_listener(self._on_stream_start_debug) + self._visualizer_handler = VisualizerHandler(on_frame=self._on_visualizer_due_frame) + self._visualizer_handler.attach_client(client) + + def _on_visualizer_batch(self, frames: list[VisualizerFrame]) -> None: + """Short log for each incoming visualizer binary batch.""" + self._visualizer_batch_count += 1 + if not frames: + logger.info("Visualizer batch #%d received: empty", self._visualizer_batch_count) + return + latest = frames[-1] + bins = len(latest.spectrum) if latest.spectrum is not None else 0 + logger.info( + "Visualizer batch #%d received: frames=%d latest_ts=%d bins=%d loud=%s", + self._visualizer_batch_count, + len(frames), + latest.timestamp_us, + bins, + latest.loudness, + ) + + def _on_stream_start_debug(self, message: StreamStartMessage) -> None: + """Log stream/start payload shape for visualizer diagnostics.""" + has_player = message.payload.player is not None + has_visualizer = message.payload.visualizer is not None + vis_types = ( + message.payload.visualizer.get("types") + if isinstance(message.payload.visualizer, dict) + else None + ) + logger.info( + "Stream/start received: player=%s visualizer=%s types=%s", + has_player, + has_visualizer, + vis_types, + ) + + def _on_visualizer_due_frame(self, frame: VisualizerFrame) -> None: + """Print one line whenever a frame should be visualized (timestamp due).""" + if frame.timestamp_us == 0 and frame.spectrum is None and frame.loudness is None: + return + bins = len(frame.spectrum) if frame.spectrum is not None else 0 + print( # noqa: T201 + "[viz-due] " + f"mono_us={int(time.monotonic() * 1_000_000)} " + f"server_ts={frame.timestamp_us} bins={bins} loud={frame.loudness}" + ) + async def run(self) -> int: """Run the daemon.""" logger.info("Starting Sendspin daemon: %s", self._args.client_id) @@ -182,6 +294,7 @@ async def _run_client_initiated(self, static_delay_ms: float) -> None: self._mpris = SendspinMpris(self._client) self._mpris.start() self._audio_handler.attach_client(self._client) + self._attach_visualizer_debug(self._client) self._server_url = self._args.url self._client.add_server_command_listener(self._handle_server_command) await self._connection_loop(self._args.url) @@ -210,6 +323,9 @@ async def _run_server_initiated(self, static_delay_ms: float) -> None: async def _handle_disconnect(self, *, stop_mpris: bool = True) -> None: """Reset connection-scoped state and optionally stop MPRIS.""" + if self._visualizer_handler is not None: + self._visualizer_handler.detach() + self._visualizer_handler = None if stop_mpris and self._mpris is not None: self._mpris.stop() self._mpris = None @@ -245,6 +361,7 @@ async def _handle_server_connection(self, ws: web.WebSocketResponse) -> None: client = self._create_client(self._static_delay_ms) self._client = client self._audio_handler.attach_client(client) + self._attach_visualizer_debug(client) client.add_server_command_listener(self._handle_server_command) if MPRIS_AVAILABLE and self._args.use_mpris: self._mpris = SendspinMpris(client) diff --git a/sendspin/decoder.py b/sendspin/decoder.py index 38eeabc..c1efee1 100644 --- a/sendspin/decoder.py +++ b/sendspin/decoder.py @@ -87,7 +87,9 @@ def _build_extradata(self) -> bytes: extract the 34-byte STREAMINFO. Otherwise, generate it from params. """ if self._codec_header and len(self._codec_header) >= _FLAC_HEADER_PREFIX_SIZE + 34: - return self._codec_header[_FLAC_HEADER_PREFIX_SIZE : _FLAC_HEADER_PREFIX_SIZE + 34] + return bytes( + self._codec_header[_FLAC_HEADER_PREFIX_SIZE : _FLAC_HEADER_PREFIX_SIZE + 34] + ) # Fallback: generate STREAMINFO from parameters (codec_header is optional per spec) streaminfo = bytearray(34) diff --git a/sendspin/serve/server.py b/sendspin/serve/server.py index be07161..4ecf451 100644 --- a/sendspin/serve/server.py +++ b/sendspin/serve/server.py @@ -7,12 +7,12 @@ from aiosendspin.server import SendspinServer -class SendspinPlayerServer(SendspinServer): +class SendspinPlayerServer(SendspinServer): # type: ignore[misc] """SendspinServer that serves an embedded web player at /.""" def _create_web_application(self) -> web.Application: """Create web app with embedded player and static file serving.""" - app = super()._create_web_application() + app: web.Application = super()._create_web_application() # Get path to web assets directory web_path = Path(str(files("sendspin.serve.web"))) diff --git a/sendspin/settings.py b/sendspin/settings.py index 222009e..c581b12 100644 --- a/sendspin/settings.py +++ b/sendspin/settings.py @@ -118,6 +118,8 @@ class ClientSettings(BaseSettings): hook_set_volume: str | None = None hook_start: str | None = None hook_stop: str | None = None + visualizer_enabled: bool = True + visualizer_smoothing_enabled: bool = True def update( self, @@ -137,6 +139,8 @@ def update( hook_set_volume: str | None = None, hook_start: str | None = None, hook_stop: str | None = None, + visualizer_enabled: bool | None = None, + visualizer_smoothing_enabled: bool | None = None, ) -> None: """Update settings fields. Only changed fields trigger a save.""" changed = False @@ -166,6 +170,8 @@ def update( "hook_set_volume": hook_set_volume, "hook_start": hook_start, "hook_stop": hook_stop, + "visualizer_enabled": visualizer_enabled, + "visualizer_smoothing_enabled": visualizer_smoothing_enabled, } ) or changed @@ -198,6 +204,8 @@ def _load(self) -> None: self.hook_set_volume = data.get("hook_set_volume") self.hook_start = data.get("hook_start") self.hook_stop = data.get("hook_stop") + self.visualizer_enabled = data.get("visualizer_enabled", True) + self.visualizer_smoothing_enabled = data.get("visualizer_smoothing_enabled", True) logger.info( "Loaded settings from %s: volume=%d%%, muted=%s", self._settings_file, diff --git a/sendspin/tui/app.py b/sendspin/tui/app.py index 2c7326c..8a7177b 100644 --- a/sendspin/tui/app.py +++ b/sendspin/tui/app.py @@ -7,8 +7,8 @@ import logging import signal import sys -from dataclasses import dataclass, field -from typing import TYPE_CHECKING +from dataclasses import dataclass, field, fields as dataclass_fields +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from aiosendspin.models.metadata import SessionUpdateMetadata @@ -27,6 +27,17 @@ PlayerCommandPayload, SupportedAudioFormat, ) +from aiosendspin.models.visualizer import ClientHelloVisualizerSupport + +try: + from aiosendspin.models.visualizer import VisualizerFrame +except ImportError: + VisualizerFrame = Any + +try: + from aiosendspin.models.visualizer import ClientHelloVisualizerSpectrum +except ImportError: + ClientHelloVisualizerSpectrum = None from aiosendspin.models.types import ( MediaCommand, PlaybackStateType, @@ -44,6 +55,7 @@ from sendspin.tui.keyboard import keyboard_loop from sendspin.tui.ui import SendspinUI from sendspin.utils import create_task, get_device_info +from sendspin.visualizer_connector import VisualizerHandler logger = logging.getLogger(__name__) @@ -221,6 +233,8 @@ class AppArgs: volume_controller: VolumeController | None = None hook_start: str | None = None hook_stop: str | None = None + visualizer_enabled: bool = True + visualizer_smoothing_enabled: bool = True class SendspinApp: @@ -240,12 +254,55 @@ def __init__(self, args: AppArgs) -> None: self._client: SendspinClient | None = None self._audio_handler: AudioStreamHandler | None = None + self._visualizer_handler: VisualizerHandler | None = None self._settings = args.settings self._discovery = ServiceDiscovery() self._connection_manager = ConnectionManager(self._discovery) self._connect_task: asyncio.Task[None] | None = None self._mpris: SendspinMpris | None = None + @staticmethod + def _ensure_draft_visualizer_api() -> None: + """Fail fast when an outdated aiosendspin package is installed.""" + if Roles.VISUALIZER.value != "visualizer@_draft_r1": + raise RuntimeError( + "Installed aiosendspin does not support visualizer@_draft_r1. " + "Please upgrade/reinstall aiosendspin from the current refactor branch." + ) + if not hasattr(SendspinClient, "add_visualizer_listener"): + raise RuntimeError( + "Installed aiosendspin client lacks visualizer listener API. " + "Please upgrade/reinstall aiosendspin from the current refactor branch." + ) + + @staticmethod + def _build_visualizer_support() -> ClientHelloVisualizerSupport: + """Build draft-r1 visualizer support payload for client/hello.""" + support_fields = {f.name for f in dataclass_fields(ClientHelloVisualizerSupport)} + if not {"types", "batch_max", "spectrum"}.issubset(support_fields): + raise RuntimeError( + "Installed aiosendspin visualizer support model is outdated. " + "Please upgrade/reinstall aiosendspin from the current refactor branch." + ) + if ClientHelloVisualizerSpectrum is None: + raise RuntimeError( + "Installed aiosendspin lacks ClientHelloVisualizerSpectrum. " + "Please upgrade/reinstall aiosendspin from the current refactor branch." + ) + + return ClientHelloVisualizerSupport( + buffer_capacity=65536, + types=["loudness", "spectrum"], + batch_max=8, + spectrum=ClientHelloVisualizerSpectrum( + n_disp_bins=48, + scale="mel", + f_min=20, + f_max=20000, + rate_max=30, + ), + ) + async def run(self) -> int: # noqa: PLR0915 """Run the application.""" args = self._args @@ -293,16 +350,20 @@ def request_shutdown() -> None: supported_formats = [f for f in supported_formats if f != args.preferred_format] supported_formats.insert(0, args.preferred_format) + self._ensure_draft_visualizer_api() + visualizer_support = self._build_visualizer_support() + self._client = SendspinClient( client_id=args.client_id, client_name=args.client_name, - roles=[Roles.CONTROLLER, Roles.PLAYER, Roles.METADATA], + roles=[Roles.CONTROLLER, Roles.PLAYER, Roles.METADATA, Roles.VISUALIZER], device_info=get_device_info(), player_support=ClientHelloPlayerSupport( supported_formats=supported_formats, buffer_capacity=32_000_000, supported_commands=[PlayerCommand.VOLUME, PlayerCommand.MUTE], ), + visualizer_support=visualizer_support, static_delay_ms=delay, initial_volume=self._audio_handler.volume, initial_muted=self._audio_handler.muted, @@ -318,6 +379,8 @@ def request_shutdown() -> None: player_volume=self._audio_handler.volume, player_muted=self._audio_handler.muted, use_external_volume=self._audio_handler.uses_external_volume_controller, + visualizer_enabled=args.visualizer_enabled, + visualizer_smoothing_enabled=args.visualizer_smoothing_enabled, ) self._ui.start() self._ui.add_event(f"Using client ID: {args.client_id}") @@ -331,6 +394,11 @@ def request_shutdown() -> None: self._client.add_server_command_listener(self._handle_server_command) self._audio_handler.attach_client(self._client) + self._visualizer_handler = VisualizerHandler( + on_frame=self._handle_visualizer_frame, + ) + self._visualizer_handler.attach_client(self._client) + if self._mpris: self._mpris.start() @@ -690,6 +758,11 @@ def _handle_format_change( assert self._ui is not None self._ui.set_audio_format(codec, sample_rate, bit_depth, channels) + def _handle_visualizer_frame(self, frame: VisualizerFrame) -> None: + """Handle a visualizer frame from the connector.""" + if self._ui is not None: + self._ui.set_visualizer_frame(frame.spectrum, frame.loudness) + def _on_stream_event(self, event: str) -> None: """Handle stream lifecycle events by running hooks.""" hook = self._args.hook_start if event == "start" else self._args.hook_stop diff --git a/sendspin/tui/keyboard.py b/sendspin/tui/keyboard.py index 2bf0b55..11d6c89 100644 --- a/sendspin/tui/keyboard.py +++ b/sendspin/tui/keyboard.py @@ -205,6 +205,20 @@ async def keyboard_loop( request_shutdown() break + # Handle 'v' to toggle visualizer + if key in "vV": + ui.highlight_shortcut("visualizer") + enabled = ui.toggle_visualizer() + settings.update(visualizer_enabled=enabled) + continue + + # Handle 'x' to toggle visualizer smoothing + if key in "xX": + ui.highlight_shortcut("smoothing") + enabled = ui.toggle_visualizer_smoothing() + settings.update(visualizer_smoothing_enabled=enabled) + continue + # Handle 's' to open server selector if key in "sS": ui.highlight_shortcut("server") diff --git a/sendspin/tui/ui.py b/sendspin/tui/ui.py index 19effca..eb08133 100644 --- a/sendspin/tui/ui.py +++ b/sendspin/tui/ui.py @@ -19,6 +19,12 @@ from rich.text import Text from sendspin.discovery import DiscoveredServer +from sendspin.tui.visualizer import ( + VisualizerState, + render_loudness_bar, + render_peak_arrow, + render_spectrum, +) from sendspin.utils import create_task @@ -85,6 +91,11 @@ class UIState: repeat_mode: RepeatMode | None = None shuffle: bool | None = None + # Visualizer + visualizer_enabled: bool = True + visualizer_smoothing_enabled: bool = True + visualizer_state: VisualizerState = field(default_factory=VisualizerState) + # Shortcut highlight highlighted_shortcut: str | None = None highlight_time: float = 0.0 @@ -100,6 +111,8 @@ def __init__( player_volume: int = 100, player_muted: bool = False, use_external_volume: bool = False, + visualizer_enabled: bool = True, + visualizer_smoothing_enabled: bool = True, ) -> None: """Initialize the UI.""" self._console = Console() @@ -109,6 +122,11 @@ def __init__( player_volume=player_volume, player_muted=player_muted, use_external_volume=use_external_volume, + visualizer_enabled=visualizer_enabled, + visualizer_smoothing_enabled=visualizer_smoothing_enabled, + ) + self._state.visualizer_state = VisualizerState( + smoothing_enabled=visualizer_smoothing_enabled ) self._live: Live | None = None self._running = False @@ -575,6 +593,27 @@ def _build_server_panel(self, *, expand: bool = False, min_info_rows: int = 0) - return Panel(content, title="Server", border_style="yellow", expand=expand) + def _build_visualizer_panel(self) -> Panel: + """Build the spectrum visualizer panel.""" + state = self._state.visualizer_state + magnitudes = state.get_spectrum() + loudness = state.loudness + + # Bar width = terminal width minus panel borders (4) and some padding + bar_width = max(10, self._console.width - 5) + peak_row = render_peak_arrow(magnitudes, bar_width) + rows = render_spectrum(magnitudes, bar_width, height=4) + loudness_row = render_loudness_bar(loudness, bar_width) + + content = Table.grid() + content.add_column() + content.add_row(peak_row) + for row in rows: + content.add_row(row) + content.add_row(loudness_row) + + return Panel(content, border_style="cyan", expand=True, padding=(0, 0)) + def _build_layout(self) -> Table: """Build the complete UI layout.""" # Get terminal width and leave 1 char margin to prevent wrapping @@ -701,6 +740,10 @@ def _build_layout(self) -> Table: top_row.add_row(now_playing, volume) layout.add_row(top_row) + # Visualizer panel (between top row and progress bar) + if self._state.visualizer_enabled: + layout.add_row(self._build_visualizer_panel()) + layout.add_row(progress) if narrow: @@ -846,6 +889,28 @@ def set_repeat_shuffle( self._state.shuffle = shuffle self.refresh() + def set_visualizer_frame(self, spectrum: list[int] | None, loudness: int | None) -> None: + """Update visualizer state with new frame data.""" + if self._state.visualizer_enabled: + self._state.visualizer_state.update(spectrum, loudness) + self.refresh() + + def toggle_visualizer(self) -> bool: + """Toggle the visualizer on/off. Returns the new state.""" + self._state.visualizer_enabled = not self._state.visualizer_enabled + if not self._state.visualizer_enabled: + self._state.visualizer_state.clear() + # Restart live display with appropriate refresh rate + self._restart_live() + return self._state.visualizer_enabled + + def toggle_visualizer_smoothing(self) -> bool: + """Toggle visualizer interpolation. Returns the new state.""" + self._state.visualizer_smoothing_enabled = not self._state.visualizer_smoothing_enabled + self._state.visualizer_state.set_smoothing_enabled(self._state.visualizer_smoothing_enabled) + self.refresh() + return self._state.visualizer_smoothing_enabled + def show_server_selector(self, servers: list[DiscoveredServer]) -> None: """Show the server selector with available servers.""" self._state.available_servers = servers @@ -880,6 +945,10 @@ def get_selected_server(self) -> DiscoveredServer | None: return self._state.available_servers[self._state.selected_server_index] return None + def _refresh_rate(self) -> int: + """Return the appropriate refresh rate based on visualizer state.""" + return 60 if self._state.visualizer_enabled else 4 + def start(self) -> None: """Start the live display.""" self._console.clear() @@ -895,6 +964,20 @@ def start(self) -> None: self._refresh_task = create_task(self._refresh_loop(), name="sendspin-ui-refresh") self.refresh() + def _restart_live(self) -> None: + """Restart the live display with updated refresh rate.""" + if not self._running: + return + if self._live is not None: + self._live.stop() + self._live = Live( + _RefreshableLayout(self), + console=self._console, + refresh_per_second=self._refresh_rate(), + screen=True, + ) + self._live.start() + def stop(self) -> None: """Stop the live display.""" self._running = False diff --git a/sendspin/tui/visualizer.py b/sendspin/tui/visualizer.py new file mode 100644 index 0000000..cdbeef9 --- /dev/null +++ b/sendspin/tui/visualizer.py @@ -0,0 +1,197 @@ +"""Visualizer rendering for the Sendspin TUI.""" + +from __future__ import annotations + +import time + +from rich.text import Text + +# Unicode block characters for bar rendering (9 levels including space) +_BLOCKS = " ▁▂▃▄▅▆▇█" +_BLOCK_LEVELS = len(_BLOCKS) - 1 # 8 + +# Color gradient across frequency bins: green -> cyan -> blue -> magenta +_GRADIENT = ["green", "green", "cyan", "cyan", "blue", "blue", "magenta", "magenta"] + + +# Interpolation response speed in units per second. +_SMOOTH_RATE_PER_SECOND = 14.0 + + +class VisualizerState: + """Stores and smooths visualizer frame data for rendering.""" + + def __init__(self, *, smoothing_enabled: bool = True) -> None: + self._spectrum: list[float] = [] + self._spectrum_target: list[float] = [] + self._loudness: float = 0.0 + self._loudness_target: float = 0.0 + self._smoothing_enabled = smoothing_enabled + self._last_step_monotonic = time.monotonic() + + def update(self, spectrum: list[int] | None, loudness: int | None) -> None: + """Update with new frame data. Values are uint16 (0-65535).""" + # Explicit empty frames are used as stream clear/end signals. + if spectrum is None and loudness is None: + self.clear() + return + + if spectrum is not None: + self._spectrum_target = [v / 65535.0 for v in spectrum] + if not self._smoothing_enabled: + self._spectrum = list(self._spectrum_target) + if loudness is not None: + self._loudness_target = loudness / 65535.0 + if not self._smoothing_enabled: + self._loudness = self._loudness_target + + def clear(self) -> None: + """Clear all state immediately.""" + self._spectrum = [] + self._spectrum_target = [] + self._loudness = 0.0 + self._loudness_target = 0.0 + self._last_step_monotonic = time.monotonic() + + def set_smoothing_enabled(self, enabled: bool) -> None: + """Enable or disable interpolation.""" + self._smoothing_enabled = enabled + self._last_step_monotonic = time.monotonic() + if not enabled: + self._spectrum = list(self._spectrum_target) + self._loudness = self._loudness_target + + @property + def smoothing_enabled(self) -> bool: + """Whether interpolation is enabled.""" + return self._smoothing_enabled + + def _step(self) -> None: + """Advance displayed values toward targets.""" + if not self._smoothing_enabled: + return + + now = time.monotonic() + dt = max(0.0, now - self._last_step_monotonic) + self._last_step_monotonic = now + if dt <= 0.0: + return + + alpha = min(1.0, dt * _SMOOTH_RATE_PER_SECOND) + + if len(self._spectrum) != len(self._spectrum_target): + self._spectrum = list(self._spectrum_target) + else: + self._spectrum = [ + current + (target - current) * alpha + for current, target in zip(self._spectrum, self._spectrum_target, strict=True) + ] + + self._loudness = self._loudness + (self._loudness_target - self._loudness) * alpha + + def get_spectrum(self) -> list[float]: + """Return the most recent normalized 0.0-1.0 spectrum values.""" + self._step() + return list(self._spectrum) + + @property + def loudness(self) -> float: + """Return current loudness without per-frame decay.""" + self._step() + return self._loudness + + +def render_spectrum(magnitudes: list[float], width: int, height: int = 2) -> list[Text]: + """Render spectrum bars as Rich Text lines. + + Args: + magnitudes: Normalized 0.0-1.0 values per frequency bin. + width: Target width in characters. + height: Number of text rows (each row = 8 block levels). + + Returns: + List of Text objects, one per row (top to bottom). + """ + if not magnitudes or width <= 0: + return [Text(" " * width) for _ in range(height)] + + # Resample to fit width + n_bins = len(magnitudes) + bars: list[float] = [] + for i in range(width): + # Map bar index to source bin range + start = i * n_bins / width + end = (i + 1) * n_bins / width + start_idx = int(start) + end_idx = min(int(end), n_bins - 1) + + # Average the bins in this range + total = 0.0 + count = 0 + for j in range(start_idx, end_idx + 1): + total += magnitudes[j] + count += 1 + value = total / count if count > 0 else 0.0 + if value > 0.0: + value = value**0.6 + bars.append(value) + + total_levels = height * _BLOCK_LEVELS + rows: list[Text] = [] + + for row in range(height): + line = Text() + # Row 0 = top, row height-1 = bottom + row_bottom = (height - 1 - row) * _BLOCK_LEVELS + + for bar_idx, value in enumerate(bars): + level = value * total_levels + if value > 0.0: + level = max(level, 1.0) + fill = level - row_bottom + + if fill >= _BLOCK_LEVELS: + char = _BLOCKS[_BLOCK_LEVELS] # Full block + elif fill <= 0: + char = " " + else: + char = _BLOCKS[int(fill)] + + # Color based on position across frequency range + gradient_idx = bar_idx * (len(_GRADIENT) - 1) // max(1, len(bars) - 1) + color = _GRADIENT[min(gradient_idx, len(_GRADIENT) - 1)] + line.append(char, style=color) + + rows.append(line) + + return rows + + +def render_peak_arrow(magnitudes: list[float], width: int) -> Text: + """Render a marker pointing to the strongest frequency bar.""" + if width <= 0: + return Text() + line = Text(" " * width, style="dim") + if not magnitudes: + return line + + n_bins = len(magnitudes) + peak_idx = max(range(n_bins), key=lambda i: magnitudes[i]) + col = min(width - 1, int(peak_idx * width / max(1, n_bins))) + line = Text(" " * col) + line.append("v", style="bold yellow") + line.append(" " * max(0, width - col - 1)) + return line + + +def render_loudness_bar(loudness: float, width: int) -> Text: + """Render a horizontal loudness bar.""" + if width <= 0: + return Text() + + clamped = max(0.0, min(1.0, loudness)) + filled = int(clamped * width) + line = Text() + line.append("█" * filled, style="bold green") + line.append("░" * max(0, width - filled), style="dim") + return line diff --git a/sendspin/visualizer_connector.py b/sendspin/visualizer_connector.py new file mode 100644 index 0000000..658bf03 --- /dev/null +++ b/sendspin/visualizer_connector.py @@ -0,0 +1,147 @@ +"""Visualizer connector for bridging Sendspin client to the TUI visualizer.""" + +from __future__ import annotations + +import logging +import asyncio +from dataclasses import dataclass +from collections.abc import Callable +from typing import TYPE_CHECKING + +try: + from aiosendspin.models.visualizer import VisualizerFrame +except ImportError: + + @dataclass + class VisualizerFrame: # type: ignore[no-redef] + """Compatibility frame when aiosendspin lacks visualizer frame model.""" + + timestamp_us: int + loudness: int | None = None + f_peak: int | None = None + spectrum: list[int] | None = None + + +if TYPE_CHECKING: + from aiosendspin.client import SendspinClient + +logger = logging.getLogger(__name__) + + +class VisualizerHandler: + """Bridges between SendspinClient visualizer data and the TUI. + + Receives VisualizerFrame batches from the client, converts timestamps + to client time, and provides the latest frame for rendering. + """ + + def __init__( + self, + on_frame: Callable[[VisualizerFrame], None], + ) -> None: + """Initialize the visualizer handler. + + Args: + on_frame: Callback invoked with the latest frame for display. + """ + self._on_frame = on_frame + self._client: SendspinClient | None = None + self._unsubscribes: list[Callable[[], None]] = [] + self._pending: list[tuple[int, VisualizerFrame]] = [] + self._timer: asyncio.TimerHandle | None = None + + def attach_client(self, client: SendspinClient) -> None: + """Attach to a SendspinClient and register listeners.""" + self._client = client + self._unsubscribes = [ + client.add_visualizer_listener(self._on_visualizer_data), + client.add_stream_end_listener(self._on_stream_end), + client.add_stream_clear_listener(self._on_stream_clear), + ] + + def detach(self) -> None: + """Detach from the client and unregister listeners.""" + for unsub in self._unsubscribes: + unsub() + self._unsubscribes = [] + if self._timer is not None: + self._timer.cancel() + self._timer = None + self._pending.clear() + self._client = None + + def _on_visualizer_data(self, frames: list[VisualizerFrame]) -> None: + """Handle incoming visualizer frames. + + Only use real spectrum frames; drop non-spectrum frames. + """ + if not frames: + return + + if self._client is None: + return + + # Queue frames by synced server timestamps (independent of local audio delay). + for frame in frames: + if frame.spectrum is None: + continue + play_time_us = self._client.compute_play_time(frame.timestamp_us) + self._pending.append((play_time_us, frame)) + + if not self._pending: + return + self._pending.sort(key=lambda item: item[0]) + self._schedule_next() + + def _on_stream_end(self, roles: list[str] | None) -> None: + """Handle stream end for visualizer role.""" + if roles is not None and "visualizer" not in roles: + return + self._pending.clear() + if self._timer is not None: + self._timer.cancel() + self._timer = None + # Send an empty frame to trigger decay + self._on_frame(VisualizerFrame(timestamp_us=0)) + + def _on_stream_clear(self, roles: list[str] | None) -> None: + """Handle stream clear for visualizer role.""" + if roles is not None and "visualizer" not in roles: + return + self._pending.clear() + if self._timer is not None: + self._timer.cancel() + self._timer = None + self._on_frame(VisualizerFrame(timestamp_us=0)) + + def _schedule_next(self) -> None: + """Schedule emission of the next due visualizer frame.""" + if self._client is None or not self._pending: + return + if self._timer is not None: + self._timer.cancel() + self._timer = None + + loop = asyncio.get_running_loop() + now_us = int(loop.time() * 1_000_000) + next_play_us = self._pending[0][0] + delay_s = max(0.0, (next_play_us - now_us) / 1_000_000.0) + self._timer = loop.call_later(delay_s, self._emit_due_frames) + + def _emit_due_frames(self) -> None: + """Emit the newest frame whose play time is due.""" + self._timer = None + if self._client is None or not self._pending: + return + + now_us = int(asyncio.get_running_loop().time() * 1_000_000) + latest_due: VisualizerFrame | None = None + while self._pending and self._pending[0][0] <= now_us: + _play_us, frame = self._pending.pop(0) + latest_due = frame + + if latest_due is not None: + self._on_frame(latest_due) + + if self._pending: + self._schedule_next() From bdf87fafb07c2fca4b591d4aae4b77d1ad565576 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Mon, 23 Mar 2026 15:19:22 +0100 Subject: [PATCH 02/14] bump aiosendspin --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 43791ca..c380cf3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] dependencies = [ - "aiosendspin~=4.3", + "aiosendspin~=4.4", "aiosendspin-mpris~=2.1.1", "av>=14.0.0", "numpy>=1.24.0", From 9f1216057eb1900f5b0f61754f6feac9c31891eb Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Tue, 24 Mar 2026 12:30:07 +0100 Subject: [PATCH 03/14] WIP --- README.md | 15 +-- sendspin/audio_connector.py | 2 +- sendspin/cli.py | 17 +-- sendspin/daemon/daemon.py | 121 +---------------- sendspin/decoder.py | 4 +- sendspin/serve/server.py | 4 +- sendspin/settings.py | 8 -- sendspin/tui/app.py | 78 ++++------- sendspin/tui/keyboard.py | 14 -- sendspin/tui/ui.py | 93 +++++-------- sendspin/tui/visualizer.py | 217 ++++++++++++++++++++----------- sendspin/visualizer_connector.py | 45 +++---- tests/tui/test_visualizer.py | 115 ++++++++++++++++ 13 files changed, 349 insertions(+), 384 deletions(-) create mode 100644 tests/tui/test_visualizer.py diff --git a/README.md b/README.md index 1a07bca..ce05c9a 100644 --- a/README.md +++ b/README.md @@ -136,8 +136,7 @@ Settings are stored in `~/.config/sendspin/`: "listen_port": 8927, "use_mpris": true, "use_hardware_volume": true, - "hook_set_volume": "/usr/local/bin/set-avr-volume", - "visualizer_enabled": true + "hook_set_volume": "/usr/local/bin/set-avr-volume" } ``` @@ -171,7 +170,6 @@ Settings are stored in `~/.config/sendspin/`: | `hook_set_volume` | string | TUI/daemon | Script to run for external volume control (`--hook-set-volume`). Receives the effective volume 0-100 as the last argument | | `hook_start` | string | TUI/daemon | Command to run when audio stream starts | | `hook_stop` | string | TUI/daemon | Command to run when audio stream stops | -| `visualizer_enabled` | boolean | TUI | Enable audio visualizer (default: true) | | `source` | string | serve | Default audio source (file path or URL, ffmpeg input) | | `source_format` | string | serve | ffmpeg container format for audio source | | `clients` | array | serve | Client URLs to connect to (`--client`) | @@ -315,16 +313,11 @@ Hooks receive these environment variables: ### Visualizer -The TUI includes a real-time audio spectrum visualizer that displays frequency data received from the server. The visualizer is enabled by default. - -**Toggle the visualizer:** - -Press `v` during playback to toggle the visualizer on or off. The setting is persisted. - -**Disable the visualizer on startup:** +The TUI includes a real-time audio spectrum visualizer that displays frequency data received from the server. This uses the experimental `visualizer@_draft_r1` role, the spectrum data is computed on the server and sent via sendspin to the TUI. +You can enable it with by running with the visualizer flag: ```bash -sendspin --no-visualizer +sendspin --visualizer ``` ### Debugging & Troubleshooting diff --git a/sendspin/audio_connector.py b/sendspin/audio_connector.py index f364090..6d7bd5f 100644 --- a/sendspin/audio_connector.py +++ b/sendspin/audio_connector.py @@ -462,7 +462,7 @@ def _on_audio_chunk( worker.submit_chunk(server_timestamp_us, audio_data, fmt) - def _on_stream_start(self, message: StreamStartMessage) -> None: + def _on_stream_start(self, _message: StreamStartMessage) -> None: """Handle stream start by clearing stale audio chunks.""" assert self._client is not None, "Received stream start but client is not attached" if self._audio_worker is None or not self._audio_worker.is_running(): diff --git a/sendspin/cli.py b/sendspin/cli.py index a9fb285..6bd5c66 100644 --- a/sendspin/cli.py +++ b/sendspin/cli.py @@ -173,16 +173,10 @@ def _add_player_runtime_options(target: ArgumentTarget, *, suppress_defaults: bo help="Command to run when audio stream stops (receives SENDSPIN_* env vars)", ) target.add_argument( - "--no-visualizer", + "--visualizer", action="store_true", default=argparse.SUPPRESS if suppress_defaults else False, - help="Disable the audio visualizer in the TUI", - ) - target.add_argument( - "--no-visualizer-smoothing", - action="store_true", - default=argparse.SUPPRESS if suppress_defaults else False, - help="Disable visualizer interpolation/smoothing in the TUI", + help="Enable the audio spectrum visualizer in the TUI", ) @@ -706,11 +700,7 @@ async def _run_client_mode(args: argparse.Namespace) -> int: f"Hardware volume control is not available on this system. " f"{HW_VOLUME_UNAVAILABLE_REASON or 'Use --hardware-volume false to disable.'}" ) - # Keep visualizer enabled by default unless explicitly disabled via CLI flag. - args.visualizer_enabled = not getattr(args, "no_visualizer", False) - args.visualizer_smoothing_enabled = settings.visualizer_smoothing_enabled and not getattr( - args, "no_visualizer_smoothing", False - ) + args.visualizer_enabled = getattr(args, "visualizer", False) if args.hook_start is None: args.hook_start = settings.hook_start if args.hook_stop is None: @@ -779,7 +769,6 @@ async def _run_client_mode(args: argparse.Namespace) -> int: hook_start=args.hook_start, hook_stop=args.hook_stop, visualizer_enabled=args.visualizer_enabled, - visualizer_smoothing_enabled=args.visualizer_smoothing_enabled, ) app = SendspinApp(app_args) diff --git a/sendspin/daemon/daemon.py b/sendspin/daemon/daemon.py index 522a278..2176883 100644 --- a/sendspin/daemon/daemon.py +++ b/sendspin/daemon/daemon.py @@ -6,9 +6,8 @@ import contextlib import logging import signal -import time -from dataclasses import dataclass, fields as dataclass_fields -from typing import TYPE_CHECKING, Any +from dataclasses import dataclass +from typing import TYPE_CHECKING from aiohttp import ClientError, web from aiosendspin.client import ClientListener, SendspinClient @@ -16,16 +15,8 @@ ClientGoodbyeMessage, ClientGoodbyePayload, ServerCommandPayload, - StreamStartMessage, ) from aiosendspin.models.player import ClientHelloPlayerSupport, SupportedAudioFormat -from aiosendspin.models.visualizer import ClientHelloVisualizerSupport - -try: - from aiosendspin.models.visualizer import ClientHelloVisualizerSpectrum, VisualizerFrame -except ImportError: - ClientHelloVisualizerSpectrum = None - VisualizerFrame = Any from aiosendspin_mpris import MPRIS_AVAILABLE, SendspinMpris from aiosendspin.models.types import ( GoodbyeReason, @@ -38,7 +29,6 @@ from sendspin.hooks import run_hook from sendspin.settings import ClientSettings from sendspin.utils import create_task, get_device_info -from sendspin.visualizer_connector import VisualizerHandler if TYPE_CHECKING: from sendspin.volume_controller import VolumeController @@ -83,16 +73,11 @@ def __init__(self, args: DaemonArgs) -> None: self._static_delay_ms: float = 0.0 self._connection_lock: asyncio.Lock | None = None self._server_url: str | None = None - self._visualizer_handler: VisualizerHandler | None = None - self._visualizer_batch_count: int = 0 def _create_client(self, static_delay_ms: float = 0.0) -> SendspinClient: """Create a new SendspinClient instance.""" assert self._audio_handler is not None client_roles = [Roles.PLAYER] - visualizer_support = self._build_visualizer_support() - if visualizer_support is not None: - client_roles.append(Roles.VISUALIZER) if MPRIS_AVAILABLE and self._args.use_mpris: client_roles.extend([Roles.METADATA, Roles.CONTROLLER]) @@ -111,108 +96,11 @@ def _create_client(self, static_delay_ms: float = 0.0) -> SendspinClient: buffer_capacity=32_000_000, supported_commands=[PlayerCommand.VOLUME, PlayerCommand.MUTE], ), - visualizer_support=visualizer_support, static_delay_ms=static_delay_ms, initial_volume=self._audio_handler.volume, initial_muted=self._audio_handler.muted, ) - @staticmethod - def _build_visualizer_support() -> ClientHelloVisualizerSupport | None: - """Build draft-r1 visualizer support payload for daemon mode.""" - support_fields = {f.name for f in dataclass_fields(ClientHelloVisualizerSupport)} - if not {"types", "batch_max", "spectrum"}.issubset(support_fields): - logger.warning( - "Installed aiosendspin visualizer support model is outdated; " - "daemon will continue without visualizer role" - ) - return None - return ClientHelloVisualizerSupport( - buffer_capacity=65536, - types=["loudness", "spectrum"], - batch_max=8, - spectrum=ClientHelloVisualizerSpectrum( - n_disp_bins=48, - scale="mel", - f_min=20, - f_max=20000, - rate_max=30, - ), - ) - - def _attach_visualizer_debug(self, client: SendspinClient) -> None: - """Attach visualizer debug callbacks for daemon mode.""" - if not hasattr(client, "add_visualizer_listener"): - logger.warning( - "Installed aiosendspin client lacks visualizer listener API; " - "visualizer debug disabled" - ) - return - # Raw binary tap: log every visualization binary payload before parsing. - raw_handler = getattr(client, "_handle_visualization_data", None) - if callable(raw_handler): - - def _wrapped_visualization_data(payload: bytes) -> None: - logger.info( - "Visualizer raw binary received: bytes=%d first8=%s", - len(payload), - payload[:8].hex(), - ) - raw_handler(payload) - - setattr(client, "_handle_visualization_data", _wrapped_visualization_data) - logger.info("Visualizer raw binary tap attached") - - self._visualizer_batch_count = 0 - client.add_visualizer_listener(self._on_visualizer_batch) - client.add_stream_start_listener(self._on_stream_start_debug) - self._visualizer_handler = VisualizerHandler(on_frame=self._on_visualizer_due_frame) - self._visualizer_handler.attach_client(client) - - def _on_visualizer_batch(self, frames: list[VisualizerFrame]) -> None: - """Short log for each incoming visualizer binary batch.""" - self._visualizer_batch_count += 1 - if not frames: - logger.info("Visualizer batch #%d received: empty", self._visualizer_batch_count) - return - latest = frames[-1] - bins = len(latest.spectrum) if latest.spectrum is not None else 0 - logger.info( - "Visualizer batch #%d received: frames=%d latest_ts=%d bins=%d loud=%s", - self._visualizer_batch_count, - len(frames), - latest.timestamp_us, - bins, - latest.loudness, - ) - - def _on_stream_start_debug(self, message: StreamStartMessage) -> None: - """Log stream/start payload shape for visualizer diagnostics.""" - has_player = message.payload.player is not None - has_visualizer = message.payload.visualizer is not None - vis_types = ( - message.payload.visualizer.get("types") - if isinstance(message.payload.visualizer, dict) - else None - ) - logger.info( - "Stream/start received: player=%s visualizer=%s types=%s", - has_player, - has_visualizer, - vis_types, - ) - - def _on_visualizer_due_frame(self, frame: VisualizerFrame) -> None: - """Print one line whenever a frame should be visualized (timestamp due).""" - if frame.timestamp_us == 0 and frame.spectrum is None and frame.loudness is None: - return - bins = len(frame.spectrum) if frame.spectrum is not None else 0 - print( # noqa: T201 - "[viz-due] " - f"mono_us={int(time.monotonic() * 1_000_000)} " - f"server_ts={frame.timestamp_us} bins={bins} loud={frame.loudness}" - ) - async def run(self) -> int: """Run the daemon.""" logger.info("Starting Sendspin daemon: %s", self._args.client_id) @@ -294,7 +182,6 @@ async def _run_client_initiated(self, static_delay_ms: float) -> None: self._mpris = SendspinMpris(self._client) self._mpris.start() self._audio_handler.attach_client(self._client) - self._attach_visualizer_debug(self._client) self._server_url = self._args.url self._client.add_server_command_listener(self._handle_server_command) await self._connection_loop(self._args.url) @@ -323,9 +210,6 @@ async def _run_server_initiated(self, static_delay_ms: float) -> None: async def _handle_disconnect(self, *, stop_mpris: bool = True) -> None: """Reset connection-scoped state and optionally stop MPRIS.""" - if self._visualizer_handler is not None: - self._visualizer_handler.detach() - self._visualizer_handler = None if stop_mpris and self._mpris is not None: self._mpris.stop() self._mpris = None @@ -361,7 +245,6 @@ async def _handle_server_connection(self, ws: web.WebSocketResponse) -> None: client = self._create_client(self._static_delay_ms) self._client = client self._audio_handler.attach_client(client) - self._attach_visualizer_debug(client) client.add_server_command_listener(self._handle_server_command) if MPRIS_AVAILABLE and self._args.use_mpris: self._mpris = SendspinMpris(client) diff --git a/sendspin/decoder.py b/sendspin/decoder.py index c1efee1..38eeabc 100644 --- a/sendspin/decoder.py +++ b/sendspin/decoder.py @@ -87,9 +87,7 @@ def _build_extradata(self) -> bytes: extract the 34-byte STREAMINFO. Otherwise, generate it from params. """ if self._codec_header and len(self._codec_header) >= _FLAC_HEADER_PREFIX_SIZE + 34: - return bytes( - self._codec_header[_FLAC_HEADER_PREFIX_SIZE : _FLAC_HEADER_PREFIX_SIZE + 34] - ) + return self._codec_header[_FLAC_HEADER_PREFIX_SIZE : _FLAC_HEADER_PREFIX_SIZE + 34] # Fallback: generate STREAMINFO from parameters (codec_header is optional per spec) streaminfo = bytearray(34) diff --git a/sendspin/serve/server.py b/sendspin/serve/server.py index 4ecf451..be07161 100644 --- a/sendspin/serve/server.py +++ b/sendspin/serve/server.py @@ -7,12 +7,12 @@ from aiosendspin.server import SendspinServer -class SendspinPlayerServer(SendspinServer): # type: ignore[misc] +class SendspinPlayerServer(SendspinServer): """SendspinServer that serves an embedded web player at /.""" def _create_web_application(self) -> web.Application: """Create web app with embedded player and static file serving.""" - app: web.Application = super()._create_web_application() + app = super()._create_web_application() # Get path to web assets directory web_path = Path(str(files("sendspin.serve.web"))) diff --git a/sendspin/settings.py b/sendspin/settings.py index c581b12..222009e 100644 --- a/sendspin/settings.py +++ b/sendspin/settings.py @@ -118,8 +118,6 @@ class ClientSettings(BaseSettings): hook_set_volume: str | None = None hook_start: str | None = None hook_stop: str | None = None - visualizer_enabled: bool = True - visualizer_smoothing_enabled: bool = True def update( self, @@ -139,8 +137,6 @@ def update( hook_set_volume: str | None = None, hook_start: str | None = None, hook_stop: str | None = None, - visualizer_enabled: bool | None = None, - visualizer_smoothing_enabled: bool | None = None, ) -> None: """Update settings fields. Only changed fields trigger a save.""" changed = False @@ -170,8 +166,6 @@ def update( "hook_set_volume": hook_set_volume, "hook_start": hook_start, "hook_stop": hook_stop, - "visualizer_enabled": visualizer_enabled, - "visualizer_smoothing_enabled": visualizer_smoothing_enabled, } ) or changed @@ -204,8 +198,6 @@ def _load(self) -> None: self.hook_set_volume = data.get("hook_set_volume") self.hook_start = data.get("hook_start") self.hook_stop = data.get("hook_stop") - self.visualizer_enabled = data.get("visualizer_enabled", True) - self.visualizer_smoothing_enabled = data.get("visualizer_smoothing_enabled", True) logger.info( "Loaded settings from %s: volume=%d%%, muted=%s", self._settings_file, diff --git a/sendspin/tui/app.py b/sendspin/tui/app.py index 8a7177b..a9d9c34 100644 --- a/sendspin/tui/app.py +++ b/sendspin/tui/app.py @@ -7,8 +7,8 @@ import logging import signal import sys -from dataclasses import dataclass, field, fields as dataclass_fields -from typing import TYPE_CHECKING, Any +from dataclasses import dataclass, field +from typing import TYPE_CHECKING if TYPE_CHECKING: from aiosendspin.models.metadata import SessionUpdateMetadata @@ -27,17 +27,11 @@ PlayerCommandPayload, SupportedAudioFormat, ) -from aiosendspin.models.visualizer import ClientHelloVisualizerSupport - -try: - from aiosendspin.models.visualizer import VisualizerFrame -except ImportError: - VisualizerFrame = Any - -try: - from aiosendspin.models.visualizer import ClientHelloVisualizerSpectrum -except ImportError: - ClientHelloVisualizerSpectrum = None +from aiosendspin.models.visualizer import ( + ClientHelloVisualizerSpectrum, + ClientHelloVisualizerSupport, + VisualizerFrame, +) from aiosendspin.models.types import ( MediaCommand, PlaybackStateType, @@ -233,8 +227,7 @@ class AppArgs: volume_controller: VolumeController | None = None hook_start: str | None = None hook_stop: str | None = None - visualizer_enabled: bool = True - visualizer_smoothing_enabled: bool = True + visualizer_enabled: bool = False class SendspinApp: @@ -261,35 +254,9 @@ def __init__(self, args: AppArgs) -> None: self._connect_task: asyncio.Task[None] | None = None self._mpris: SendspinMpris | None = None - @staticmethod - def _ensure_draft_visualizer_api() -> None: - """Fail fast when an outdated aiosendspin package is installed.""" - if Roles.VISUALIZER.value != "visualizer@_draft_r1": - raise RuntimeError( - "Installed aiosendspin does not support visualizer@_draft_r1. " - "Please upgrade/reinstall aiosendspin from the current refactor branch." - ) - if not hasattr(SendspinClient, "add_visualizer_listener"): - raise RuntimeError( - "Installed aiosendspin client lacks visualizer listener API. " - "Please upgrade/reinstall aiosendspin from the current refactor branch." - ) - @staticmethod def _build_visualizer_support() -> ClientHelloVisualizerSupport: - """Build draft-r1 visualizer support payload for client/hello.""" - support_fields = {f.name for f in dataclass_fields(ClientHelloVisualizerSupport)} - if not {"types", "batch_max", "spectrum"}.issubset(support_fields): - raise RuntimeError( - "Installed aiosendspin visualizer support model is outdated. " - "Please upgrade/reinstall aiosendspin from the current refactor branch." - ) - if ClientHelloVisualizerSpectrum is None: - raise RuntimeError( - "Installed aiosendspin lacks ClientHelloVisualizerSpectrum. " - "Please upgrade/reinstall aiosendspin from the current refactor branch." - ) - + """Build visualizer support payload for client/hello.""" return ClientHelloVisualizerSupport( buffer_capacity=65536, types=["loudness", "spectrum"], @@ -350,13 +317,16 @@ def request_shutdown() -> None: supported_formats = [f for f in supported_formats if f != args.preferred_format] supported_formats.insert(0, args.preferred_format) - self._ensure_draft_visualizer_api() - visualizer_support = self._build_visualizer_support() + client_roles = [Roles.CONTROLLER, Roles.PLAYER, Roles.METADATA] + visualizer_support = None + if args.visualizer_enabled: + visualizer_support = self._build_visualizer_support() + client_roles.append(Roles.VISUALIZER) self._client = SendspinClient( client_id=args.client_id, client_name=args.client_name, - roles=[Roles.CONTROLLER, Roles.PLAYER, Roles.METADATA, Roles.VISUALIZER], + roles=client_roles, device_info=get_device_info(), player_support=ClientHelloPlayerSupport( supported_formats=supported_formats, @@ -380,7 +350,6 @@ def request_shutdown() -> None: player_muted=self._audio_handler.muted, use_external_volume=self._audio_handler.uses_external_volume_controller, visualizer_enabled=args.visualizer_enabled, - visualizer_smoothing_enabled=args.visualizer_smoothing_enabled, ) self._ui.start() self._ui.add_event(f"Using client ID: {args.client_id}") @@ -394,10 +363,11 @@ def request_shutdown() -> None: self._client.add_server_command_listener(self._handle_server_command) self._audio_handler.attach_client(self._client) - self._visualizer_handler = VisualizerHandler( - on_frame=self._handle_visualizer_frame, - ) - self._visualizer_handler.attach_client(self._client) + if args.visualizer_enabled: + self._visualizer_handler = VisualizerHandler( + on_frame=self._handle_visualizer_frame, + ) + self._visualizer_handler.attach_client(self._client) if self._mpris: self._mpris.start() @@ -464,12 +434,14 @@ def signal_handler() -> None: finally: if self._mpris: self._mpris.stop() + if self._visualizer_handler: + self._visualizer_handler.detach() if self._ui: self._ui.stop() if self._audio_handler: await self._audio_handler.shutdown() - assert self._client is not None - await self._client.disconnect() + if self._client is not None: + await self._client.disconnect() await self._discovery.stop() await self._settings.flush() @@ -494,6 +466,8 @@ async def _handle_disconnect(self, message: str) -> None: logger.info(message) self._ui.add_event(message) self._ui.set_disconnected(message) + if self._visualizer_handler: + self._visualizer_handler.reset() await self._audio_handler.handle_disconnect() async def _connect_cancellable(self, url: str) -> None: diff --git a/sendspin/tui/keyboard.py b/sendspin/tui/keyboard.py index 11d6c89..2bf0b55 100644 --- a/sendspin/tui/keyboard.py +++ b/sendspin/tui/keyboard.py @@ -205,20 +205,6 @@ async def keyboard_loop( request_shutdown() break - # Handle 'v' to toggle visualizer - if key in "vV": - ui.highlight_shortcut("visualizer") - enabled = ui.toggle_visualizer() - settings.update(visualizer_enabled=enabled) - continue - - # Handle 'x' to toggle visualizer smoothing - if key in "xX": - ui.highlight_shortcut("smoothing") - enabled = ui.toggle_visualizer_smoothing() - settings.update(visualizer_smoothing_enabled=enabled) - continue - # Handle 's' to open server selector if key in "sS": ui.highlight_shortcut("server") diff --git a/sendspin/tui/ui.py b/sendspin/tui/ui.py index eb08133..659e880 100644 --- a/sendspin/tui/ui.py +++ b/sendspin/tui/ui.py @@ -21,8 +21,6 @@ from sendspin.discovery import DiscoveredServer from sendspin.tui.visualizer import ( VisualizerState, - render_loudness_bar, - render_peak_arrow, render_spectrum, ) from sendspin.utils import create_task @@ -44,6 +42,7 @@ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderR REFRESH_COALESCE_DELAY = 1 / 30 PLAYBACK_REFRESH_INTERVAL = 0.25 HIGHLIGHT_REFRESH_INTERVAL = 0.05 +VISUALIZER_REFRESH_INTERVAL = 1 / 60 RESIZE_POLL_INTERVAL = 0.25 @@ -92,8 +91,7 @@ class UIState: shuffle: bool | None = None # Visualizer - visualizer_enabled: bool = True - visualizer_smoothing_enabled: bool = True + visualizer_enabled: bool = False visualizer_state: VisualizerState = field(default_factory=VisualizerState) # Shortcut highlight @@ -111,8 +109,7 @@ def __init__( player_volume: int = 100, player_muted: bool = False, use_external_volume: bool = False, - visualizer_enabled: bool = True, - visualizer_smoothing_enabled: bool = True, + visualizer_enabled: bool = False, ) -> None: """Initialize the UI.""" self._console = Console() @@ -123,10 +120,6 @@ def __init__( player_muted=player_muted, use_external_volume=use_external_volume, visualizer_enabled=visualizer_enabled, - visualizer_smoothing_enabled=visualizer_smoothing_enabled, - ) - self._state.visualizer_state = VisualizerState( - smoothing_enabled=visualizer_smoothing_enabled ) self._live: Live | None = None self._running = False @@ -173,6 +166,12 @@ def _needs_playback_refresh(self) -> bool: and (self._state.track_duration_ms or 0) > 0 ) + def _needs_visualizer_refresh(self) -> bool: + """Check if the visualizer needs periodic refreshes for interpolation.""" + return self._state.visualizer_enabled and bool( + self._state.visualizer_state._spectrum_target + ) + def _next_refresh_interval(self) -> float | None: """Return the next periodic refresh interval, if any.""" intervals: list[float] = [] @@ -180,6 +179,8 @@ def _next_refresh_interval(self) -> float | None: intervals.append(PLAYBACK_REFRESH_INTERVAL) if self._has_active_highlight(): intervals.append(HIGHLIGHT_REFRESH_INTERVAL) + if self._needs_visualizer_refresh(): + intervals.append(VISUALIZER_REFRESH_INTERVAL) return min(intervals) if intervals else None def _flush_refresh(self, *, force: bool = False) -> None: @@ -593,26 +594,22 @@ def _build_server_panel(self, *, expand: bool = False, min_info_rows: int = 0) - return Panel(content, title="Server", border_style="yellow", expand=expand) - def _build_visualizer_panel(self) -> Panel: - """Build the spectrum visualizer panel.""" + def _build_visualizer_rows(self, height: int) -> list[Text]: + """Build the spectrum visualizer as raw Text rows.""" state = self._state.visualizer_state magnitudes = state.get_spectrum() loudness = state.loudness + peaks = state.get_peaks() - # Bar width = terminal width minus panel borders (4) and some padding - bar_width = max(10, self._console.width - 5) - peak_row = render_peak_arrow(magnitudes, bar_width) - rows = render_spectrum(magnitudes, bar_width, height=4) - loudness_row = render_loudness_bar(loudness, bar_width) - - content = Table.grid() - content.add_column() - content.add_row(peak_row) - for row in rows: - content.add_row(row) - content.add_row(loudness_row) + bar_width = max(10, self._console.width - 1) + return render_spectrum(magnitudes, bar_width, height, loudness, peaks) - return Panel(content, border_style="cyan", expand=True, padding=(0, 0)) + def _measure_layout_height(self, layout: Table) -> int: + """Measure the rendered height of a layout table.""" + lines = 0 + for segment in self._console.render(layout): + lines += str(segment.text).count("\n") + return lines def _build_layout(self) -> Table: """Build the complete UI layout.""" @@ -740,10 +737,6 @@ def _build_layout(self) -> Table: top_row.add_row(now_playing, volume) layout.add_row(top_row) - # Visualizer panel (between top row and progress bar) - if self._state.visualizer_enabled: - layout.add_row(self._build_visualizer_panel()) - layout.add_row(progress) if narrow: @@ -764,6 +757,14 @@ def _build_layout(self) -> Table: quit_line.append(" quit ", style="dim") layout.add_row(quit_line) + # Visualizer: fill remaining terminal space + if self._state.visualizer_enabled: + panel_height = self._measure_layout_height(layout) + remaining = self._console.height - panel_height + if remaining >= 3: + for row in self._build_visualizer_rows(remaining): + layout.add_row(row) + return layout def add_event(self, _message: str) -> None: @@ -895,22 +896,6 @@ def set_visualizer_frame(self, spectrum: list[int] | None, loudness: int | None) self._state.visualizer_state.update(spectrum, loudness) self.refresh() - def toggle_visualizer(self) -> bool: - """Toggle the visualizer on/off. Returns the new state.""" - self._state.visualizer_enabled = not self._state.visualizer_enabled - if not self._state.visualizer_enabled: - self._state.visualizer_state.clear() - # Restart live display with appropriate refresh rate - self._restart_live() - return self._state.visualizer_enabled - - def toggle_visualizer_smoothing(self) -> bool: - """Toggle visualizer interpolation. Returns the new state.""" - self._state.visualizer_smoothing_enabled = not self._state.visualizer_smoothing_enabled - self._state.visualizer_state.set_smoothing_enabled(self._state.visualizer_smoothing_enabled) - self.refresh() - return self._state.visualizer_smoothing_enabled - def show_server_selector(self, servers: list[DiscoveredServer]) -> None: """Show the server selector with available servers.""" self._state.available_servers = servers @@ -945,10 +930,6 @@ def get_selected_server(self) -> DiscoveredServer | None: return self._state.available_servers[self._state.selected_server_index] return None - def _refresh_rate(self) -> int: - """Return the appropriate refresh rate based on visualizer state.""" - return 60 if self._state.visualizer_enabled else 4 - def start(self) -> None: """Start the live display.""" self._console.clear() @@ -964,20 +945,6 @@ def start(self) -> None: self._refresh_task = create_task(self._refresh_loop(), name="sendspin-ui-refresh") self.refresh() - def _restart_live(self) -> None: - """Restart the live display with updated refresh rate.""" - if not self._running: - return - if self._live is not None: - self._live.stop() - self._live = Live( - _RefreshableLayout(self), - console=self._console, - refresh_per_second=self._refresh_rate(), - screen=True, - ) - self._live.start() - def stop(self) -> None: """Stop the live display.""" self._running = False diff --git a/sendspin/tui/visualizer.py b/sendspin/tui/visualizer.py index cdbeef9..bfa7afc 100644 --- a/sendspin/tui/visualizer.py +++ b/sendspin/tui/visualizer.py @@ -10,40 +10,85 @@ _BLOCKS = " ▁▂▃▄▅▆▇█" _BLOCK_LEVELS = len(_BLOCKS) - 1 # 8 -# Color gradient across frequency bins: green -> cyan -> blue -> magenta -_GRADIENT = ["green", "green", "cyan", "cyan", "blue", "blue", "magenta", "magenta"] - - # Interpolation response speed in units per second. _SMOOTH_RATE_PER_SECOND = 14.0 +# Peak hold configuration +_PEAK_HOLD_SECONDS = 0.5 +_PEAK_FALL_RATE = 0.375 # normalized units per second (≈6 rows/sec at 16 rows) + +# Loudness-to-color tier stops: (loudness_threshold, (R, G, B)) +_COLOR_TIERS: list[tuple[float, tuple[int, int, int]]] = [ + (0.00, (0x33, 0x55, 0x88)), # steel blue + (0.05, (0x33, 0x66, 0x88)), # blue-teal + (0.10, (0x33, 0x77, 0x77)), # teal + (0.20, (0x44, 0x88, 0x66)), # sea green + (0.35, (0x66, 0x88, 0x44)), # olive + (0.55, (0x88, 0x77, 0x33)), # amber + (0.75, (0x99, 0x55, 0x33)), # warm brown +] + + +def _rgb_to_hex(r: int, g: int, b: int) -> str: + """Convert RGB to a hex color string for Rich.""" + return f"#{r:02x}{g:02x}{b:02x}" + + +def _lerp_rgb(c0: tuple[int, int, int], c1: tuple[int, int, int], t: float) -> tuple[int, int, int]: + """Linearly interpolate between two RGB colors.""" + return ( + int(c0[0] + (c1[0] - c0[0]) * t), + int(c0[1] + (c1[1] - c0[1]) * t), + int(c0[2] + (c1[2] - c0[2]) * t), + ) + + +def loudness_to_colors( + loudness: float, +) -> tuple[tuple[int, int, int], tuple[int, int, int]]: + """Map a 0.0-1.0 loudness value to (tip_rgb, base_rgb). + + Tip color is interpolated between tier stops. + Base color is the tip at 25% brightness. + """ + loudness = max(0.0, min(1.0, loudness)) + + for i in range(len(_COLOR_TIERS) - 1): + t0, c0 = _COLOR_TIERS[i] + t1, c1 = _COLOR_TIERS[i + 1] + if loudness <= t1: + t = (loudness - t0) / (t1 - t0) if t1 > t0 else 0.0 + tip = _lerp_rgb(c0, c1, t) + base = (tip[0] // 4, tip[1] // 4, tip[2] // 4) + return tip, base + + tip = _COLOR_TIERS[-1][1] + base = (tip[0] // 4, tip[1] // 4, tip[2] // 4) + return tip, base + class VisualizerState: """Stores and smooths visualizer frame data for rendering.""" - def __init__(self, *, smoothing_enabled: bool = True) -> None: + def __init__(self) -> None: self._spectrum: list[float] = [] self._spectrum_target: list[float] = [] self._loudness: float = 0.0 self._loudness_target: float = 0.0 - self._smoothing_enabled = smoothing_enabled + self._peaks: list[float] = [] + self._peak_hold_timers: list[float] = [] self._last_step_monotonic = time.monotonic() def update(self, spectrum: list[int] | None, loudness: int | None) -> None: """Update with new frame data. Values are uint16 (0-65535).""" - # Explicit empty frames are used as stream clear/end signals. if spectrum is None and loudness is None: self.clear() return if spectrum is not None: self._spectrum_target = [v / 65535.0 for v in spectrum] - if not self._smoothing_enabled: - self._spectrum = list(self._spectrum_target) if loudness is not None: self._loudness_target = loudness / 65535.0 - if not self._smoothing_enabled: - self._loudness = self._loudness_target def clear(self) -> None: """Clear all state immediately.""" @@ -51,26 +96,12 @@ def clear(self) -> None: self._spectrum_target = [] self._loudness = 0.0 self._loudness_target = 0.0 + self._peaks = [] + self._peak_hold_timers = [] self._last_step_monotonic = time.monotonic() - def set_smoothing_enabled(self, enabled: bool) -> None: - """Enable or disable interpolation.""" - self._smoothing_enabled = enabled - self._last_step_monotonic = time.monotonic() - if not enabled: - self._spectrum = list(self._spectrum_target) - self._loudness = self._loudness_target - - @property - def smoothing_enabled(self) -> bool: - """Whether interpolation is enabled.""" - return self._smoothing_enabled - def _step(self) -> None: """Advance displayed values toward targets.""" - if not self._smoothing_enabled: - return - now = time.monotonic() dt = max(0.0, now - self._last_step_monotonic) self._last_step_monotonic = now @@ -89,6 +120,24 @@ def _step(self) -> None: self._loudness = self._loudness + (self._loudness_target - self._loudness) * alpha + # Update peaks + if len(self._peaks) != len(self._spectrum): + self._peaks = list(self._spectrum) + self._peak_hold_timers = [_PEAK_HOLD_SECONDS] * len(self._spectrum) + else: + for i, value in enumerate(self._spectrum): + if value >= self._peaks[i]: + self._peaks[i] = value + self._peak_hold_timers[i] = _PEAK_HOLD_SECONDS + elif self._peak_hold_timers[i] > 0: + remaining = self._peak_hold_timers[i] + self._peak_hold_timers[i] -= dt + if self._peak_hold_timers[i] <= 0: + decay_dt = dt - remaining + self._peaks[i] = max(0.0, self._peaks[i] - _PEAK_FALL_RATE * decay_dt) + else: + self._peaks[i] = max(0.0, self._peaks[i] - _PEAK_FALL_RATE * dt) + def get_spectrum(self) -> list[float]: """Return the most recent normalized 0.0-1.0 spectrum values.""" self._step() @@ -100,49 +149,93 @@ def loudness(self) -> float: self._step() return self._loudness + def get_peaks(self) -> list[float]: + """Return the current peak hold heights (0.0-1.0 per bin).""" + self._step() + return list(self._peaks) + -def render_spectrum(magnitudes: list[float], width: int, height: int = 2) -> list[Text]: - """Render spectrum bars as Rich Text lines. +def render_spectrum( + magnitudes: list[float], + width: int, + height: int, + loudness: float, + peaks: list[float], +) -> list[Text]: + """Render spectrum bars as Rich Text lines with loudness-driven color. Args: magnitudes: Normalized 0.0-1.0 values per frequency bin. width: Target width in characters. height: Number of text rows (each row = 8 block levels). + loudness: Normalized 0.0-1.0 loudness for color selection. + peaks: Normalized 0.0-1.0 peak hold heights per bin. Returns: List of Text objects, one per row (top to bottom). """ - if not magnitudes or width <= 0: - return [Text(" " * width) for _ in range(height)] + if not magnitudes or width <= 0 or height <= 0: + return [Text(" " * max(0, width)) for _ in range(max(0, height))] + + tip, base = loudness_to_colors(loudness) - # Resample to fit width + # Find frequency peak bin (for highlight color on its peak marker) + freq_peak_bin = max(range(len(magnitudes)), key=lambda i: magnitudes[i]) + + # Resample magnitudes and peaks to fit width n_bins = len(magnitudes) bars: list[float] = [] + bar_peaks: list[float] = [] + bar_is_freq_peak: list[bool] = [] for i in range(width): - # Map bar index to source bin range start = i * n_bins / width end = (i + 1) * n_bins / width start_idx = int(start) end_idx = min(int(end), n_bins - 1) - # Average the bins in this range total = 0.0 + peak_max = 0.0 + is_freq_peak = False count = 0 for j in range(start_idx, end_idx + 1): total += magnitudes[j] + if peaks and j < len(peaks): + peak_max = max(peak_max, peaks[j]) + if j == freq_peak_bin: + is_freq_peak = True count += 1 value = total / count if count > 0 else 0.0 if value > 0.0: value = value**0.6 bars.append(value) + bar_peaks.append(peak_max**0.6 if peak_max > 0 else 0.0) + bar_is_freq_peak.append(is_freq_peak) total_levels = height * _BLOCK_LEVELS rows: list[Text] = [] + # Pre-compute row colors (vertical gradient from base to tip) + # Square root curve so bars brighten quickly from the base + row_colors: list[str] = [] for row in range(height): + # Row 0 = top (brightest), row height-1 = bottom (darkest) + linear_t = 1.0 - row / max(1, height - 1) if height > 1 else 1.0 + t = linear_t**0.5 # sqrt curve: more of the bar sits in brighter range + rgb = _lerp_rgb(base, tip, t) + row_colors.append(_rgb_to_hex(*rgb)) + + # Peak marker colors + peak_color = _rgb_to_hex( + min(255, int(tip[0] * 1.4)), + min(255, int(tip[1] * 1.4)), + min(255, int(tip[2] * 1.4)), + ) + freq_peak_color = "#ffffff" + + for row_idx in range(height): line = Text() - # Row 0 = top, row height-1 = bottom - row_bottom = (height - 1 - row) * _BLOCK_LEVELS + row_bottom = (height - 1 - row_idx) * _BLOCK_LEVELS + color = row_colors[row_idx] for bar_idx, value in enumerate(bars): level = value * total_levels @@ -150,48 +243,22 @@ def render_spectrum(magnitudes: list[float], width: int, height: int = 2) -> lis level = max(level, 1.0) fill = level - row_bottom - if fill >= _BLOCK_LEVELS: - char = _BLOCKS[_BLOCK_LEVELS] # Full block + # Check for peak marker at this position + peak_level = bar_peaks[bar_idx] * total_levels + if peak_level > 0.0: + peak_level = max(peak_level, 1.0) + peak_row_pos = peak_level - row_bottom + + if 0 < peak_row_pos <= _BLOCK_LEVELS and fill < peak_row_pos: + pc = freq_peak_color if bar_is_freq_peak[bar_idx] else peak_color + line.append("▔", style=pc) + elif fill >= _BLOCK_LEVELS: + line.append(_BLOCKS[_BLOCK_LEVELS], style=color) elif fill <= 0: - char = " " + line.append(" ") else: - char = _BLOCKS[int(fill)] - - # Color based on position across frequency range - gradient_idx = bar_idx * (len(_GRADIENT) - 1) // max(1, len(bars) - 1) - color = _GRADIENT[min(gradient_idx, len(_GRADIENT) - 1)] - line.append(char, style=color) + line.append(_BLOCKS[int(fill)], style=color) rows.append(line) return rows - - -def render_peak_arrow(magnitudes: list[float], width: int) -> Text: - """Render a marker pointing to the strongest frequency bar.""" - if width <= 0: - return Text() - line = Text(" " * width, style="dim") - if not magnitudes: - return line - - n_bins = len(magnitudes) - peak_idx = max(range(n_bins), key=lambda i: magnitudes[i]) - col = min(width - 1, int(peak_idx * width / max(1, n_bins))) - line = Text(" " * col) - line.append("v", style="bold yellow") - line.append(" " * max(0, width - col - 1)) - return line - - -def render_loudness_bar(loudness: float, width: int) -> Text: - """Render a horizontal loudness bar.""" - if width <= 0: - return Text() - - clamped = max(0.0, min(1.0, loudness)) - filled = int(clamped * width) - line = Text() - line.append("█" * filled, style="bold green") - line.append("░" * max(0, width - filled), style="dim") - return line diff --git a/sendspin/visualizer_connector.py b/sendspin/visualizer_connector.py index 658bf03..fc1a9e9 100644 --- a/sendspin/visualizer_connector.py +++ b/sendspin/visualizer_connector.py @@ -2,25 +2,14 @@ from __future__ import annotations -import logging import asyncio -from dataclasses import dataclass +import logging +from collections import deque from collections.abc import Callable from typing import TYPE_CHECKING -try: - from aiosendspin.models.visualizer import VisualizerFrame -except ImportError: - - @dataclass - class VisualizerFrame: # type: ignore[no-redef] - """Compatibility frame when aiosendspin lacks visualizer frame model.""" - - timestamp_us: int - loudness: int | None = None - f_peak: int | None = None - spectrum: list[int] | None = None - +from aiosendspin.models.core import StreamStartMessage +from aiosendspin.models.visualizer import VisualizerFrame if TYPE_CHECKING: from aiosendspin.client import SendspinClient @@ -47,7 +36,7 @@ def __init__( self._on_frame = on_frame self._client: SendspinClient | None = None self._unsubscribes: list[Callable[[], None]] = [] - self._pending: list[tuple[int, VisualizerFrame]] = [] + self._pending: deque[tuple[int, VisualizerFrame]] = deque() self._timer: asyncio.TimerHandle | None = None def attach_client(self, client: SendspinClient) -> None: @@ -55,19 +44,25 @@ def attach_client(self, client: SendspinClient) -> None: self._client = client self._unsubscribes = [ client.add_visualizer_listener(self._on_visualizer_data), + client.add_stream_start_listener(self._on_stream_start), client.add_stream_end_listener(self._on_stream_end), client.add_stream_clear_listener(self._on_stream_clear), ] + def reset(self) -> None: + """Clear pending frames and cancel scheduled emissions.""" + if self._timer is not None: + self._timer.cancel() + self._timer = None + self._pending.clear() + self._on_frame(VisualizerFrame(timestamp_us=0)) + def detach(self) -> None: """Detach from the client and unregister listeners.""" for unsub in self._unsubscribes: unsub() self._unsubscribes = [] - if self._timer is not None: - self._timer.cancel() - self._timer = None - self._pending.clear() + self.reset() self._client = None def _on_visualizer_data(self, frames: list[VisualizerFrame]) -> None: @@ -90,9 +85,15 @@ def _on_visualizer_data(self, frames: list[VisualizerFrame]) -> None: if not self._pending: return - self._pending.sort(key=lambda item: item[0]) self._schedule_next() + def _on_stream_start(self, _message: StreamStartMessage) -> None: + """Flush stale frames when a new stream begins.""" + if self._timer is not None: + self._timer.cancel() + self._timer = None + self._pending.clear() + def _on_stream_end(self, roles: list[str] | None) -> None: """Handle stream end for visualizer role.""" if roles is not None and "visualizer" not in roles: @@ -137,7 +138,7 @@ def _emit_due_frames(self) -> None: now_us = int(asyncio.get_running_loop().time() * 1_000_000) latest_due: VisualizerFrame | None = None while self._pending and self._pending[0][0] <= now_us: - _play_us, frame = self._pending.pop(0) + _play_us, frame = self._pending.popleft() latest_due = frame if latest_due is not None: diff --git a/tests/tui/test_visualizer.py b/tests/tui/test_visualizer.py new file mode 100644 index 0000000..082270c --- /dev/null +++ b/tests/tui/test_visualizer.py @@ -0,0 +1,115 @@ +"""Tests for the TUI visualizer rendering.""" + +import time +from unittest.mock import patch + +from sendspin.tui.visualizer import VisualizerState, loudness_to_colors, render_spectrum + + +# --- loudness_to_colors tests --- + + +def test_loudness_zero_returns_first_tier(): + tip, base = loudness_to_colors(0.0) + assert tip == (0x33, 0x55, 0x88) + assert base == (0x33 // 4, 0x55 // 4, 0x88 // 4) + + +def test_loudness_full_returns_last_tier(): + tip, base = loudness_to_colors(1.0) + assert tip == (0x99, 0x55, 0x33) + assert base == (0x99 // 4, 0x55 // 4, 0x33 // 4) + + +def test_loudness_at_tier_boundary(): + tip, _base = loudness_to_colors(0.20) + # At 20% we hit sea green exactly + assert tip == (0x44, 0x88, 0x66) + + +def test_loudness_between_tiers_interpolates(): + tip, _base = loudness_to_colors(0.025) + # Halfway between steel blue and blue-teal + assert 0x33 <= tip[0] <= 0x33 + assert 0x55 < tip[1] < 0x66 + assert 0x77 < tip[2] <= 0x88 + + +# --- VisualizerState peak hold tests --- + + +def test_peaks_snap_to_bar_height(): + state = VisualizerState() + state.update([32768, 65535, 16384], loudness=32768) + spectrum = state.get_spectrum() + peaks = state.get_peaks() + assert len(peaks) == len(spectrum) + assert peaks == spectrum + + +def test_peaks_hold_when_bars_drop(): + state = VisualizerState() + state.update([65535, 65535], loudness=32768) + initial_spectrum = state.get_spectrum() + initial_peaks = state.get_peaks() + + state.update([0, 0], loudness=32768) + _ = state.get_spectrum() + peaks_after_drop = state.get_peaks() + assert peaks_after_drop[0] >= initial_peaks[0] * 0.9 + + +def test_peaks_decay_after_hold(): + state = VisualizerState() + state.update([65535, 65535], loudness=32768) + _ = state.get_spectrum() + _ = state.get_peaks() + + state.update([0, 0], loudness=32768) + + base = time.monotonic() + call_count = 0 + + def advancing_monotonic() -> float: + nonlocal call_count + call_count += 1 + return base + 1.0 + call_count * 0.001 + + with patch("sendspin.tui.visualizer.time") as mock_time: + mock_time.monotonic.side_effect = advancing_monotonic + peaks = state.get_peaks() + assert peaks[0] < 0.9 + + +def test_peaks_cleared_on_clear(): + state = VisualizerState() + state.update([65535], loudness=32768) + _ = state.get_spectrum() + _ = state.get_peaks() + state.clear() + assert state.get_peaks() == [] + + +# --- render_spectrum tests --- + + +def test_render_spectrum_returns_correct_row_count(): + magnitudes = [0.5] * 10 + peaks = [0.8] * 10 + rows = render_spectrum(magnitudes, width=20, height=8, loudness=0.5, peaks=peaks) + assert len(rows) == 8 + + +def test_render_spectrum_empty_magnitudes(): + rows = render_spectrum([], width=20, height=4, loudness=0.5, peaks=[]) + assert len(rows) == 4 + for row in rows: + assert row.plain.strip() == "" + + +def test_render_spectrum_peak_marker_character(): + magnitudes = [0.5] + peaks = [0.9] + rows = render_spectrum(magnitudes, width=1, height=8, loudness=0.5, peaks=peaks) + all_chars = "".join(row.plain for row in rows) + assert "▔" in all_chars From 61de9bdfd33f62edfd9dce9ef7736225bd8d9aca Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Tue, 24 Mar 2026 12:33:06 +0100 Subject: [PATCH 04/14] Lint --- tests/tui/test_visualizer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/tui/test_visualizer.py b/tests/tui/test_visualizer.py index 082270c..10ed2e8 100644 --- a/tests/tui/test_visualizer.py +++ b/tests/tui/test_visualizer.py @@ -50,7 +50,7 @@ def test_peaks_snap_to_bar_height(): def test_peaks_hold_when_bars_drop(): state = VisualizerState() state.update([65535, 65535], loudness=32768) - initial_spectrum = state.get_spectrum() + _ = state.get_spectrum() initial_peaks = state.get_peaks() state.update([0, 0], loudness=32768) From 63d0721e9be096c2512c0a4072487aebc6826694 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Wed, 25 Mar 2026 14:28:46 +0100 Subject: [PATCH 05/14] feat: add visualizer field to ClientSettings Co-Authored-By: Claude Sonnet 4.6 --- sendspin/settings.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sendspin/settings.py b/sendspin/settings.py index 222009e..4f89d65 100644 --- a/sendspin/settings.py +++ b/sendspin/settings.py @@ -118,6 +118,7 @@ class ClientSettings(BaseSettings): hook_set_volume: str | None = None hook_start: str | None = None hook_stop: str | None = None + visualizer: bool = False def update( self, @@ -137,6 +138,7 @@ def update( hook_set_volume: str | None = None, hook_start: str | None = None, hook_stop: str | None = None, + visualizer: bool | None = None, ) -> None: """Update settings fields. Only changed fields trigger a save.""" changed = False @@ -166,6 +168,7 @@ def update( "hook_set_volume": hook_set_volume, "hook_start": hook_start, "hook_stop": hook_stop, + "visualizer": visualizer, } ) or changed @@ -198,6 +201,7 @@ def _load(self) -> None: self.hook_set_volume = data.get("hook_set_volume") self.hook_start = data.get("hook_start") self.hook_stop = data.get("hook_stop") + self.visualizer = data.get("visualizer", False) logger.info( "Loaded settings from %s: volume=%d%%, muted=%s", self._settings_file, From 96278658ccbf7aae61a021539fef59717c0a03f1 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Wed, 25 Mar 2026 14:31:33 +0100 Subject: [PATCH 06/14] feat: replace --visualizer flag with settings-based default --- sendspin/cli.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/sendspin/cli.py b/sendspin/cli.py index 6bd5c66..b9fa282 100644 --- a/sendspin/cli.py +++ b/sendspin/cli.py @@ -172,12 +172,6 @@ def _add_player_runtime_options(target: ArgumentTarget, *, suppress_defaults: bo default=default, help="Command to run when audio stream stops (receives SENDSPIN_* env vars)", ) - target.add_argument( - "--visualizer", - action="store_true", - default=argparse.SUPPRESS if suppress_defaults else False, - help="Enable the audio spectrum visualizer in the TUI", - ) def _add_player_actions(target: ArgumentTarget, *, suppress_defaults: bool = False) -> None: @@ -700,7 +694,7 @@ async def _run_client_mode(args: argparse.Namespace) -> int: f"Hardware volume control is not available on this system. " f"{HW_VOLUME_UNAVAILABLE_REASON or 'Use --hardware-volume false to disable.'}" ) - args.visualizer_enabled = getattr(args, "visualizer", False) + args.visualizer_enabled = settings.visualizer if args.hook_start is None: args.hook_start = settings.hook_start if args.hook_stop is None: From 223ae210de8f50d724298c2a4f6759b0fc2d23f6 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Wed, 25 Mar 2026 14:34:16 +0100 Subject: [PATCH 07/14] feat: use client getter in CommandHandler, add v shortcut for visualizer toggle Co-Authored-By: Claude Sonnet 4.6 --- sendspin/tui/app.py | 6 +++++- sendspin/tui/keyboard.py | 34 +++++++++++++++++++++++----------- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/sendspin/tui/app.py b/sendspin/tui/app.py index a9d9c34..332e3fb 100644 --- a/sendspin/tui/app.py +++ b/sendspin/tui/app.py @@ -375,7 +375,7 @@ def request_shutdown() -> None: # Start keyboard loop for interactive control create_task( keyboard_loop( - self._client, + lambda: self._client, self._state, self._audio_handler, self._ui, @@ -383,6 +383,7 @@ def request_shutdown() -> None: self._show_server_selector, self._on_server_selected, request_shutdown, + on_toggle_visualizer=self._toggle_visualizer, ) ) @@ -732,6 +733,9 @@ def _handle_format_change( assert self._ui is not None self._ui.set_audio_format(codec, sample_rate, bit_depth, channels) + async def _toggle_visualizer(self) -> None: + """Toggle the visualizer on/off.""" + def _handle_visualizer_frame(self, frame: VisualizerFrame) -> None: """Handle a visualizer frame from the connector.""" if self._ui is not None: diff --git a/sendspin/tui/keyboard.py b/sendspin/tui/keyboard.py index 2bf0b55..60e7410 100644 --- a/sendspin/tui/keyboard.py +++ b/sendspin/tui/keyboard.py @@ -26,14 +26,14 @@ class CommandHandler: def __init__( self, - client: SendspinClient, + get_client: Callable[[], SendspinClient | None], state: AppState, audio_handler: AudioStreamHandler, ui: SendspinUI, settings: ClientSettings, ) -> None: """Initialize the command handler.""" - self._client = client + self._get_client = get_client self._state = state self._audio_handler = audio_handler self._ui = ui @@ -44,7 +44,9 @@ async def send_media_command(self, command: MediaCommand) -> None: if command not in self._state.supported_commands: self._ui.add_event(f"Server does not support {command.value}") return - await self._client.send_group_command(command) + client = self._get_client() + if client is not None: + await client.send_group_command(command) async def toggle_play_pause(self) -> None: """Toggle between play and pause.""" @@ -72,7 +74,9 @@ async def change_group_volume(self, delta: int) -> None: return current = self._state.volume or 0 target = max(0, min(100, current + delta)) - await self._client.send_group_command(MediaCommand.VOLUME, volume=target) + client = self._get_client() + if client is not None: + await client.send_group_command(MediaCommand.VOLUME, volume=target) async def toggle_group_mute(self) -> None: """Toggle group mute state.""" @@ -80,7 +84,9 @@ async def toggle_group_mute(self) -> None: self._ui.add_event("Server does not support mute control") return muted = not self._state.muted - await self._client.send_group_command(MediaCommand.MUTE, mute=muted) + client = self._get_client() + if client is not None: + await client.send_group_command(MediaCommand.MUTE, mute=muted) async def cycle_repeat(self) -> None: """Cycle repeat mode: OFF -> ALL -> ONE -> OFF.""" @@ -102,9 +108,12 @@ async def toggle_shuffle(self) -> None: async def adjust_delay(self, delta: float) -> None: """Adjust static delay by delta milliseconds.""" - self._client.set_static_delay_ms(self._client.static_delay_ms + delta) - self._ui.set_delay(self._client.static_delay_ms) - self._settings.update(static_delay_ms=self._client.static_delay_ms) + client = self._get_client() + if client is None: + return + client.set_static_delay_ms(client.static_delay_ms + delta) + self._ui.set_delay(client.static_delay_ms) + self._settings.update(static_delay_ms=client.static_delay_ms) def close_server_selector(self) -> None: """Close the server selector panel.""" @@ -112,7 +121,7 @@ def close_server_selector(self) -> None: async def keyboard_loop( - client: SendspinClient, + get_client: Callable[[], SendspinClient | None], state: AppState, audio_handler: AudioStreamHandler, ui: SendspinUI, @@ -120,11 +129,12 @@ async def keyboard_loop( show_server_selector: Callable[[], None], on_server_selected: Callable[[], Awaitable[None]], request_shutdown: Callable[[], None], + on_toggle_visualizer: Callable[[], Awaitable[None]], ) -> None: """Run the keyboard input loop. Args: - client: Sendspin client instance. + get_client: Callable returning the current Sendspin client instance. state: Application state. audio_handler: Audio stream handler. ui: UI instance. @@ -132,8 +142,9 @@ async def keyboard_loop( show_server_selector: Function to show the server selector UI. on_server_selected: Async callback when a server is selected. request_shutdown: Callback to request application shutdown. + on_toggle_visualizer: Async callback to toggle the visualizer. """ - handler = CommandHandler(client, state, audio_handler, ui, settings) + handler = CommandHandler(get_client, state, audio_handler, ui, settings) # Key dispatch table: key -> (highlight_name | None, action) # Actions can be sync or async. For keys that need case-insensitive matching, use lowercase. @@ -144,6 +155,7 @@ async def keyboard_loop( "g": ("switch", lambda: handler.send_media_command(MediaCommand.SWITCH)), "r": ("repeat", handler.cycle_repeat), "x": ("shuffle", handler.toggle_shuffle), + "v": ("visualizer", on_toggle_visualizer), # Delay adjustment ",": ("delay-", lambda: handler.adjust_delay(-10)), ".": ("delay+", lambda: handler.adjust_delay(10)), From 765f2383e7ee69ab410aef71225cc3d3359bcc96 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Wed, 25 Mar 2026 14:35:35 +0100 Subject: [PATCH 08/14] feat: add set_visualizer_enabled and v shortcut to UI Co-Authored-By: Claude Sonnet 4.6 --- sendspin/tui/ui.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/sendspin/tui/ui.py b/sendspin/tui/ui.py index 659e880..df16a11 100644 --- a/sendspin/tui/ui.py +++ b/sendspin/tui/ui.py @@ -751,8 +751,10 @@ def _build_layout(self) -> Table: bottom_row.add_row(playback, stream, server) layout.add_row(bottom_row) - # Quit shortcut below boxes + # Bottom shortcuts below boxes quit_line = Text(justify="right") + quit_line.append("v", style=self._shortcut_style("visualizer")) + quit_line.append(" visualizer ", style="dim") quit_line.append("q", style=self._shortcut_style("quit")) quit_line.append(" quit ", style="dim") layout.add_row(quit_line) @@ -896,6 +898,13 @@ def set_visualizer_frame(self, spectrum: list[int] | None, loudness: int | None) self._state.visualizer_state.update(spectrum, loudness) self.refresh() + def set_visualizer_enabled(self, enabled: bool) -> None: + """Update whether the visualizer is enabled.""" + self._state.visualizer_enabled = enabled + if not enabled: + self._state.visualizer_state.clear() + self.refresh() + def show_server_selector(self, servers: list[DiscoveredServer]) -> None: """Show the server selector with available servers.""" self._state.available_servers = servers From 2ad7d28e9a778958c75508100c7283527d1dd3ab Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Wed, 25 Mar 2026 14:39:20 +0100 Subject: [PATCH 09/14] feat: implement visualizer toggle via v shortcut with reconnect Co-Authored-By: Claude Sonnet 4.6 --- sendspin/cli.py | 2 - sendspin/tui/app.py | 140 ++++++++++++++++++++++++++++++-------------- 2 files changed, 97 insertions(+), 45 deletions(-) diff --git a/sendspin/cli.py b/sendspin/cli.py index b9fa282..97ffcfd 100644 --- a/sendspin/cli.py +++ b/sendspin/cli.py @@ -694,7 +694,6 @@ async def _run_client_mode(args: argparse.Namespace) -> int: f"Hardware volume control is not available on this system. " f"{HW_VOLUME_UNAVAILABLE_REASON or 'Use --hardware-volume false to disable.'}" ) - args.visualizer_enabled = settings.visualizer if args.hook_start is None: args.hook_start = settings.hook_start if args.hook_stop is None: @@ -762,7 +761,6 @@ async def _run_client_mode(args: argparse.Namespace) -> int: volume_controller=volume_controller, hook_start=args.hook_start, hook_stop=args.hook_stop, - visualizer_enabled=args.visualizer_enabled, ) app = SendspinApp(app_args) diff --git a/sendspin/tui/app.py b/sendspin/tui/app.py index 332e3fb..6b73913 100644 --- a/sendspin/tui/app.py +++ b/sendspin/tui/app.py @@ -7,6 +7,7 @@ import logging import signal import sys +from collections.abc import Callable from dataclasses import dataclass, field from typing import TYPE_CHECKING @@ -227,7 +228,6 @@ class AppArgs: volume_controller: VolumeController | None = None hook_start: str | None = None hook_stop: str | None = None - visualizer_enabled: bool = False class SendspinApp: @@ -249,10 +249,12 @@ def __init__(self, args: AppArgs) -> None: self._audio_handler: AudioStreamHandler | None = None self._visualizer_handler: VisualizerHandler | None = None self._settings = args.settings + self._visualizer_enabled: bool = args.settings.visualizer self._discovery = ServiceDiscovery() self._connection_manager = ConnectionManager(self._discovery) self._connect_task: asyncio.Task[None] | None = None self._mpris: SendspinMpris | None = None + self._listener_unsubscribes: list[Callable[[], None]] = [] @staticmethod def _build_visualizer_support() -> ClientHelloVisualizerSupport: @@ -270,6 +272,78 @@ def _build_visualizer_support() -> ClientHelloVisualizerSupport: ), ) + def _create_client(self) -> SendspinClient: + """Create a new SendspinClient with roles based on current visualizer state.""" + args = self._args + roles = [Roles.CONTROLLER, Roles.PLAYER, Roles.METADATA] + visualizer_support = None + if self._visualizer_enabled: + visualizer_support = self._build_visualizer_support() + roles.append(Roles.VISUALIZER) + + assert self._audio_handler is not None + delay = ( + args.static_delay_ms + if args.static_delay_ms is not None + else self._settings.static_delay_ms + ) + + return SendspinClient( + client_id=args.client_id, + client_name=args.client_name, + roles=roles, + device_info=get_device_info(), + player_support=ClientHelloPlayerSupport( + supported_formats=self._supported_formats, + buffer_capacity=32_000_000, + supported_commands=[PlayerCommand.VOLUME, PlayerCommand.MUTE], + ), + visualizer_support=visualizer_support, + static_delay_ms=delay, + initial_volume=self._audio_handler.volume, + initial_muted=self._audio_handler.muted, + ) + + def _attach_client(self) -> None: + """Attach listeners, audio handler, visualizer, and MPRIS to the current client.""" + assert self._client is not None + assert self._audio_handler is not None + + self._listener_unsubscribes = [ + self._client.add_metadata_listener(self._handle_metadata_update), + self._client.add_group_update_listener(self._handle_group_update), + self._client.add_controller_state_listener(self._handle_server_state), + self._client.add_server_command_listener(self._handle_server_command), + ] + self._audio_handler.attach_client(self._client) + + if self._visualizer_enabled: + self._visualizer_handler = VisualizerHandler( + on_frame=self._handle_visualizer_frame, + ) + self._visualizer_handler.attach_client(self._client) + + if MPRIS_AVAILABLE and self._args.use_mpris: + self._mpris = SendspinMpris(self._client) + self._mpris.start() + + def _detach_client(self) -> None: + """Detach listeners, audio handler, visualizer, and MPRIS from the current client.""" + assert self._audio_handler is not None + + for unsub in self._listener_unsubscribes: + unsub() + self._listener_unsubscribes = [] + self._audio_handler.detach_client() + + if self._visualizer_handler: + self._visualizer_handler.detach() + self._visualizer_handler = None + + if self._mpris: + self._mpris.stop() + self._mpris = None + async def run(self) -> int: # noqa: PLR0915 """Run the application.""" args = self._args @@ -316,31 +390,9 @@ def request_shutdown() -> None: if args.preferred_format is not None: supported_formats = [f for f in supported_formats if f != args.preferred_format] supported_formats.insert(0, args.preferred_format) + self._supported_formats = supported_formats - client_roles = [Roles.CONTROLLER, Roles.PLAYER, Roles.METADATA] - visualizer_support = None - if args.visualizer_enabled: - visualizer_support = self._build_visualizer_support() - client_roles.append(Roles.VISUALIZER) - - self._client = SendspinClient( - client_id=args.client_id, - client_name=args.client_name, - roles=client_roles, - device_info=get_device_info(), - player_support=ClientHelloPlayerSupport( - supported_formats=supported_formats, - buffer_capacity=32_000_000, - supported_commands=[PlayerCommand.VOLUME, PlayerCommand.MUTE], - ), - visualizer_support=visualizer_support, - static_delay_ms=delay, - initial_volume=self._audio_handler.volume, - initial_muted=self._audio_handler.muted, - ) - - if MPRIS_AVAILABLE and args.use_mpris: - self._mpris = SendspinMpris(self._client) + self._client = self._create_client() await self._audio_handler.start_volume_monitor() @@ -349,7 +401,7 @@ def request_shutdown() -> None: player_volume=self._audio_handler.volume, player_muted=self._audio_handler.muted, use_external_volume=self._audio_handler.uses_external_volume_controller, - visualizer_enabled=args.visualizer_enabled, + visualizer_enabled=self._visualizer_enabled, ) self._ui.start() self._ui.add_event(f"Using client ID: {args.client_id}") @@ -357,20 +409,7 @@ def request_shutdown() -> None: await self._discovery.start() - self._client.add_metadata_listener(self._handle_metadata_update) - self._client.add_group_update_listener(self._handle_group_update) - self._client.add_controller_state_listener(self._handle_server_state) - self._client.add_server_command_listener(self._handle_server_command) - self._audio_handler.attach_client(self._client) - - if args.visualizer_enabled: - self._visualizer_handler = VisualizerHandler( - on_frame=self._handle_visualizer_frame, - ) - self._visualizer_handler.attach_client(self._client) - - if self._mpris: - self._mpris.start() + self._attach_client() # Start keyboard loop for interactive control create_task( @@ -528,7 +567,6 @@ async def _connection_loop(self, *, already_connected: bool = False) -> None: assert self._ui is not None manager = self._connection_manager ui = self._ui - client = self._client discovery = self._discovery url = self._state.selected_server.url manager.set_last_attempted_url(url) @@ -553,7 +591,8 @@ async def _connection_loop(self, *, already_connected: bool = False) -> None: # Wait for disconnect disconnect_event: asyncio.Event = asyncio.Event() - unsubscribe = client.add_disconnect_listener(disconnect_event.set) + assert self._client is not None + unsubscribe = self._client.add_disconnect_listener(disconnect_event.set) await disconnect_event.wait() unsubscribe() @@ -734,7 +773,22 @@ def _handle_format_change( self._ui.set_audio_format(codec, sample_rate, bit_depth, channels) async def _toggle_visualizer(self) -> None: - """Toggle the visualizer on/off.""" + """Toggle the visualizer on/off, reconnecting with updated roles.""" + assert self._ui is not None + + self._visualizer_enabled = not self._visualizer_enabled + self._settings.update(visualizer=self._visualizer_enabled) + self._ui.set_visualizer_enabled(self._visualizer_enabled) + + old_client = self._client + self._detach_client() # detach from old (still self._client) + + self._client = self._create_client() + self._attach_client() # attach to new self._client + + if old_client is not None: + if not self._cancel_connect(): + await old_client.disconnect() def _handle_visualizer_frame(self, frame: VisualizerFrame) -> None: """Handle a visualizer frame from the connector.""" From bd42e0df8b25653ca5c51239040d2b1e77e08616 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Wed, 25 Mar 2026 14:43:53 +0100 Subject: [PATCH 10/14] fix: use pending-server mechanism in toggle to prevent CancelledError crash When toggling the visualizer during an in-flight connection, the cancelled connect task's CancelledError would propagate unhandled and shut down the app. By setting the current server as pending, _connect_cancellable catches it as ServerSwitchRequested and the connection loop gracefully reconnects. Co-Authored-By: Claude Opus 4.6 (1M context) --- sendspin/tui/app.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/sendspin/tui/app.py b/sendspin/tui/app.py index 6b73913..a65adab 100644 --- a/sendspin/tui/app.py +++ b/sendspin/tui/app.py @@ -787,6 +787,11 @@ async def _toggle_visualizer(self) -> None: self._attach_client() # attach to new self._client if old_client is not None: + # Reuse server-switch mechanism so the connection loop treats the + # client swap as a reconnect (prevents CancelledError propagation + # when a connect is in-flight). + if self._state.selected_server: + self._connection_manager.set_pending_server(self._state.selected_server) if not self._cancel_connect(): await old_client.disconnect() From a919ee3e1df242eaa7a25ebcda09e23a22448ecd Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Wed, 25 Mar 2026 14:45:51 +0100 Subject: [PATCH 11/14] test: update tests for visualizer toggle refactor Update CommandHandler instantiation in test_volume_state.py to use get_client callable parameter instead of the removed client keyword argument, following the API change in f21c71b. Co-Authored-By: Claude Sonnet 4.6 --- tests/tui/test_volume_state.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/tui/test_volume_state.py b/tests/tui/test_volume_state.py index 6da51d9..551e2c7 100644 --- a/tests/tui/test_volume_state.py +++ b/tests/tui/test_volume_state.py @@ -80,7 +80,7 @@ def test_keyboard_volume_change_uses_audio_handler_state(tmp_path: Path) -> None audio_handler = _FakeAudioHandler(volume=41, muted=False) ui = _FakeUI() handler = CommandHandler( - client=SimpleNamespace(), + get_client=lambda: SimpleNamespace(), state=state, audio_handler=audio_handler, ui=ui, @@ -97,7 +97,7 @@ def test_keyboard_toggle_mute_uses_audio_handler_state(tmp_path: Path) -> None: audio_handler = _FakeAudioHandler(volume=41, muted=False) ui = _FakeUI() handler = CommandHandler( - client=SimpleNamespace(), + get_client=lambda: SimpleNamespace(), state=state, audio_handler=audio_handler, ui=ui, From 697f1480e398af307b47ddda4c28d22b9e7c7435 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Wed, 25 Mar 2026 14:40:29 +0100 Subject: [PATCH 12/14] Fix silence on reconnect when visualizer is enabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the visualizer role is active, the server sends two separate stream/start messages — one for player and one for visualizer. The audio and visualizer handlers were unconditionally clearing their buffers on every stream/start, so the second (visualizer) stream/start would wipe the player's catch-up audio buffer, causing silence. Now each handler checks whether the stream/start is relevant to its role before acting. Co-Authored-By: Claude Opus 4.6 (1M context) --- sendspin/audio_connector.py | 4 +++- sendspin/visualizer_connector.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/sendspin/audio_connector.py b/sendspin/audio_connector.py index 6d7bd5f..1127d47 100644 --- a/sendspin/audio_connector.py +++ b/sendspin/audio_connector.py @@ -462,8 +462,10 @@ def _on_audio_chunk( worker.submit_chunk(server_timestamp_us, audio_data, fmt) - def _on_stream_start(self, _message: StreamStartMessage) -> None: + def _on_stream_start(self, message: StreamStartMessage) -> None: """Handle stream start by clearing stale audio chunks.""" + if message.payload.player is None: + return assert self._client is not None, "Received stream start but client is not attached" if self._audio_worker is None or not self._audio_worker.is_running(): self._audio_worker = None diff --git a/sendspin/visualizer_connector.py b/sendspin/visualizer_connector.py index fc1a9e9..c58e8c0 100644 --- a/sendspin/visualizer_connector.py +++ b/sendspin/visualizer_connector.py @@ -87,8 +87,10 @@ def _on_visualizer_data(self, frames: list[VisualizerFrame]) -> None: return self._schedule_next() - def _on_stream_start(self, _message: StreamStartMessage) -> None: + def _on_stream_start(self, message: StreamStartMessage) -> None: """Flush stale frames when a new stream begins.""" + if message.payload.visualizer is None: + return if self._timer is not None: self._timer.cancel() self._timer = None From 420f6d75d12bdf713a0910e74fac1d30c2de4b88 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Wed, 25 Mar 2026 14:57:56 +0100 Subject: [PATCH 13/14] fix: update test for stream/start role filtering, add regression test The cherry-picked stream/start role check made the existing test fail because it passed a bare object() instead of a message with payload. Fixed the test and added a new one verifying that a visualizer-only stream/start does not touch the audio worker. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/test_audio_connector.py | 38 ++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/tests/test_audio_connector.py b/tests/test_audio_connector.py index f495d2d..9124b72 100644 --- a/tests/test_audio_connector.py +++ b/tests/test_audio_connector.py @@ -135,7 +135,11 @@ async def exercise() -> None: assert not _FakeWorker.instances[0].running fmt = _make_format() - handler._on_stream_start(object()) + # Simulate a player stream/start message (payload.player must be set) + stream_start = SimpleNamespace( + payload=SimpleNamespace(player=SimpleNamespace(), visualizer=None) + ) + handler._on_stream_start(stream_start) assert len(_FakeWorker.instances) == 2 restarted_worker = _FakeWorker.instances[1] @@ -150,6 +154,38 @@ async def exercise() -> None: asyncio.run(exercise()) +def test_visualizer_stream_start_does_not_clear_audio_worker(monkeypatch) -> None: + """A visualizer-only stream/start must not touch the audio worker.""" + monkeypatch.setattr(audio_connector, "_AudioSyncWorker", _FakeWorker) + _FakeWorker.instances.clear() + + async def exercise() -> None: + handler = AudioStreamHandler( + audio_device=SimpleNamespace(index=0, name="Fake Device"), + volume=10, + muted=False, + ) + client = _FakeClient() + handler.attach_client(client) + await asyncio.sleep(0) + + assert len(_FakeWorker.instances) == 1 + worker = _FakeWorker.instances[0] + assert worker.running + + # Send a visualizer-only stream/start (no player payload) + vis_stream_start = SimpleNamespace( + payload=SimpleNamespace(player=None, visualizer=SimpleNamespace()) + ) + handler._on_stream_start(vis_stream_start) + + # Worker should be untouched — still the same one, still running + assert len(_FakeWorker.instances) == 1 + assert worker.running + + asyncio.run(exercise()) + + def test_attach_client_replaces_previous_client_listeners(monkeypatch) -> None: monkeypatch.setattr(audio_connector, "_AudioSyncWorker", _FakeWorker) _FakeWorker.instances.clear() From 336c540c9d18ac798636ba9a718f4fbc98068260 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Wed, 25 Mar 2026 15:23:42 +0100 Subject: [PATCH 14/14] Adress copilot suggestions --- README.md | 7 ++----- sendspin/tui/ui.py | 5 ++--- sendspin/tui/visualizer.py | 15 ++++++++++++--- sendspin/visualizer_connector.py | 5 +++-- tests/tui/test_visualizer.py | 28 +++++++++++++++++----------- 5 files changed, 36 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index ce05c9a..85fce3c 100644 --- a/README.md +++ b/README.md @@ -313,12 +313,9 @@ Hooks receive these environment variables: ### Visualizer -The TUI includes a real-time audio spectrum visualizer that displays frequency data received from the server. This uses the experimental `visualizer@_draft_r1` role, the spectrum data is computed on the server and sent via sendspin to the TUI. -You can enable it with by running with the visualizer flag: +The TUI includes a real-time audio spectrum visualizer that displays frequency data received from the server. This uses the experimental `visualizer@_draft_r1` role. The spectrum data is computed on the server and sent via sendspin to the TUI. -```bash -sendspin --visualizer -``` +Toggle it by pressing `v` in the TUI. Your preference is saved in settings and remembered on next launch. ### Debugging & Troubleshooting diff --git a/sendspin/tui/ui.py b/sendspin/tui/ui.py index df16a11..8cb06ad 100644 --- a/sendspin/tui/ui.py +++ b/sendspin/tui/ui.py @@ -168,9 +168,7 @@ def _needs_playback_refresh(self) -> bool: def _needs_visualizer_refresh(self) -> bool: """Check if the visualizer needs periodic refreshes for interpolation.""" - return self._state.visualizer_enabled and bool( - self._state.visualizer_state._spectrum_target - ) + return self._state.visualizer_enabled and self._state.visualizer_state.is_active def _next_refresh_interval(self) -> float | None: """Return the next periodic refresh interval, if any.""" @@ -597,6 +595,7 @@ def _build_server_panel(self, *, expand: bool = False, min_info_rows: int = 0) - def _build_visualizer_rows(self, height: int) -> list[Text]: """Build the spectrum visualizer as raw Text rows.""" state = self._state.visualizer_state + state.step() magnitudes = state.get_spectrum() loudness = state.loudness peaks = state.get_peaks() diff --git a/sendspin/tui/visualizer.py b/sendspin/tui/visualizer.py index bfa7afc..192b6f5 100644 --- a/sendspin/tui/visualizer.py +++ b/sendspin/tui/visualizer.py @@ -138,20 +138,29 @@ def _step(self) -> None: else: self._peaks[i] = max(0.0, self._peaks[i] - _PEAK_FALL_RATE * dt) + @property + def is_active(self) -> bool: + """Whether there is pending visualizer data to animate.""" + return bool(self._spectrum_target) + + def step(self) -> None: + """Advance displayed values toward targets. + + Call once per render frame before reading spectrum/loudness/peaks. + """ + self._step() + def get_spectrum(self) -> list[float]: """Return the most recent normalized 0.0-1.0 spectrum values.""" - self._step() return list(self._spectrum) @property def loudness(self) -> float: """Return current loudness without per-frame decay.""" - self._step() return self._loudness def get_peaks(self) -> list[float]: """Return the current peak hold heights (0.0-1.0 per bin).""" - self._step() return list(self._peaks) diff --git a/sendspin/visualizer_connector.py b/sendspin/visualizer_connector.py index c58e8c0..29f7ecd 100644 --- a/sendspin/visualizer_connector.py +++ b/sendspin/visualizer_connector.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING from aiosendspin.models.core import StreamStartMessage +from aiosendspin.models.types import Roles from aiosendspin.models.visualizer import VisualizerFrame if TYPE_CHECKING: @@ -98,7 +99,7 @@ def _on_stream_start(self, message: StreamStartMessage) -> None: def _on_stream_end(self, roles: list[str] | None) -> None: """Handle stream end for visualizer role.""" - if roles is not None and "visualizer" not in roles: + if roles is not None and Roles.VISUALIZER.value not in roles: return self._pending.clear() if self._timer is not None: @@ -109,7 +110,7 @@ def _on_stream_end(self, roles: list[str] | None) -> None: def _on_stream_clear(self, roles: list[str] | None) -> None: """Handle stream clear for visualizer role.""" - if roles is not None and "visualizer" not in roles: + if roles is not None and Roles.VISUALIZER.value not in roles: return self._pending.clear() if self._timer is not None: diff --git a/tests/tui/test_visualizer.py b/tests/tui/test_visualizer.py index 10ed2e8..f838108 100644 --- a/tests/tui/test_visualizer.py +++ b/tests/tui/test_visualizer.py @@ -9,25 +9,25 @@ # --- loudness_to_colors tests --- -def test_loudness_zero_returns_first_tier(): +def test_loudness_zero_returns_first_tier() -> None: tip, base = loudness_to_colors(0.0) assert tip == (0x33, 0x55, 0x88) assert base == (0x33 // 4, 0x55 // 4, 0x88 // 4) -def test_loudness_full_returns_last_tier(): +def test_loudness_full_returns_last_tier() -> None: tip, base = loudness_to_colors(1.0) assert tip == (0x99, 0x55, 0x33) assert base == (0x99 // 4, 0x55 // 4, 0x33 // 4) -def test_loudness_at_tier_boundary(): +def test_loudness_at_tier_boundary() -> None: tip, _base = loudness_to_colors(0.20) # At 20% we hit sea green exactly assert tip == (0x44, 0x88, 0x66) -def test_loudness_between_tiers_interpolates(): +def test_loudness_between_tiers_interpolates() -> None: tip, _base = loudness_to_colors(0.025) # Halfway between steel blue and blue-teal assert 0x33 <= tip[0] <= 0x33 @@ -38,30 +38,34 @@ def test_loudness_between_tiers_interpolates(): # --- VisualizerState peak hold tests --- -def test_peaks_snap_to_bar_height(): +def test_peaks_snap_to_bar_height() -> None: state = VisualizerState() state.update([32768, 65535, 16384], loudness=32768) + state.step() spectrum = state.get_spectrum() peaks = state.get_peaks() assert len(peaks) == len(spectrum) assert peaks == spectrum -def test_peaks_hold_when_bars_drop(): +def test_peaks_hold_when_bars_drop() -> None: state = VisualizerState() state.update([65535, 65535], loudness=32768) + state.step() _ = state.get_spectrum() initial_peaks = state.get_peaks() state.update([0, 0], loudness=32768) + state.step() _ = state.get_spectrum() peaks_after_drop = state.get_peaks() assert peaks_after_drop[0] >= initial_peaks[0] * 0.9 -def test_peaks_decay_after_hold(): +def test_peaks_decay_after_hold() -> None: state = VisualizerState() state.update([65535, 65535], loudness=32768) + state.step() _ = state.get_spectrum() _ = state.get_peaks() @@ -77,13 +81,15 @@ def advancing_monotonic() -> float: with patch("sendspin.tui.visualizer.time") as mock_time: mock_time.monotonic.side_effect = advancing_monotonic + state.step() peaks = state.get_peaks() assert peaks[0] < 0.9 -def test_peaks_cleared_on_clear(): +def test_peaks_cleared_on_clear() -> None: state = VisualizerState() state.update([65535], loudness=32768) + state.step() _ = state.get_spectrum() _ = state.get_peaks() state.clear() @@ -93,21 +99,21 @@ def test_peaks_cleared_on_clear(): # --- render_spectrum tests --- -def test_render_spectrum_returns_correct_row_count(): +def test_render_spectrum_returns_correct_row_count() -> None: magnitudes = [0.5] * 10 peaks = [0.8] * 10 rows = render_spectrum(magnitudes, width=20, height=8, loudness=0.5, peaks=peaks) assert len(rows) == 8 -def test_render_spectrum_empty_magnitudes(): +def test_render_spectrum_empty_magnitudes() -> None: rows = render_spectrum([], width=20, height=4, loudness=0.5, peaks=[]) assert len(rows) == 4 for row in rows: assert row.plain.strip() == "" -def test_render_spectrum_peak_marker_character(): +def test_render_spectrum_peak_marker_character() -> None: magnitudes = [0.5] peaks = [0.9] rows = render_spectrum(magnitudes, width=1, height=8, loudness=0.5, peaks=peaks)