diff --git a/README.md b/README.md index 4998ce1..85fce3c 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,12 @@ 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. This uses the experimental `visualizer@_draft_r1` role. The spectrum data is computed on the server and sent via sendspin to the TUI. + +Toggle it by pressing `v` in the TUI. Your preference is saved in settings and remembered on next launch. + ### Debugging & Troubleshooting If you experience synchronization issues or audio glitches, you can enable detailed logging to help diagnose the problem: 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", 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/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, diff --git a/sendspin/tui/app.py b/sendspin/tui/app.py index 2c7326c..a65adab 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 @@ -27,6 +28,11 @@ PlayerCommandPayload, SupportedAudioFormat, ) +from aiosendspin.models.visualizer import ( + ClientHelloVisualizerSpectrum, + ClientHelloVisualizerSupport, + VisualizerFrame, +) from aiosendspin.models.types import ( MediaCommand, PlaybackStateType, @@ -44,6 +50,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__) @@ -240,11 +247,102 @@ 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._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: + """Build visualizer support payload for client/hello.""" + 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 _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.""" @@ -292,24 +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 - self._client = SendspinClient( - client_id=args.client_id, - client_name=args.client_name, - roles=[Roles.CONTROLLER, Roles.PLAYER, Roles.METADATA], - device_info=get_device_info(), - player_support=ClientHelloPlayerSupport( - supported_formats=supported_formats, - buffer_capacity=32_000_000, - supported_commands=[PlayerCommand.VOLUME, PlayerCommand.MUTE], - ), - 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() @@ -318,6 +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=self._visualizer_enabled, ) self._ui.start() self._ui.add_event(f"Using client ID: {args.client_id}") @@ -325,19 +409,12 @@ 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 self._mpris: - self._mpris.start() + self._attach_client() # Start keyboard loop for interactive control create_task( keyboard_loop( - self._client, + lambda: self._client, self._state, self._audio_handler, self._ui, @@ -345,6 +422,7 @@ def request_shutdown() -> None: self._show_server_selector, self._on_server_selected, request_shutdown, + on_toggle_visualizer=self._toggle_visualizer, ) ) @@ -396,12 +474,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() @@ -426,6 +506,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: @@ -485,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) @@ -510,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() @@ -690,6 +772,34 @@ 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, 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: + # 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() + + 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..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)), diff --git a/sendspin/tui/ui.py b/sendspin/tui/ui.py index 19effca..8cb06ad 100644 --- a/sendspin/tui/ui.py +++ b/sendspin/tui/ui.py @@ -19,6 +19,10 @@ from rich.text import Text from sendspin.discovery import DiscoveredServer +from sendspin.tui.visualizer import ( + VisualizerState, + render_spectrum, +) from sendspin.utils import create_task @@ -38,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 @@ -85,6 +90,10 @@ class UIState: repeat_mode: RepeatMode | None = None shuffle: bool | None = None + # Visualizer + visualizer_enabled: bool = False + visualizer_state: VisualizerState = field(default_factory=VisualizerState) + # Shortcut highlight highlighted_shortcut: str | None = None highlight_time: float = 0.0 @@ -100,6 +109,7 @@ def __init__( player_volume: int = 100, player_muted: bool = False, use_external_volume: bool = False, + visualizer_enabled: bool = False, ) -> None: """Initialize the UI.""" self._console = Console() @@ -109,6 +119,7 @@ def __init__( player_volume=player_volume, player_muted=player_muted, use_external_volume=use_external_volume, + visualizer_enabled=visualizer_enabled, ) self._live: Live | None = None self._running = False @@ -155,6 +166,10 @@ 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 self._state.visualizer_state.is_active + def _next_refresh_interval(self) -> float | None: """Return the next periodic refresh interval, if any.""" intervals: list[float] = [] @@ -162,6 +177,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: @@ -575,6 +592,24 @@ 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_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() + + bar_width = max(10, self._console.width - 1) + return render_spectrum(magnitudes, bar_width, height, loudness, peaks) + + 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.""" # Get terminal width and leave 1 char margin to prevent wrapping @@ -715,12 +750,22 @@ 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) + # 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: @@ -846,6 +891,19 @@ 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 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 diff --git a/sendspin/tui/visualizer.py b/sendspin/tui/visualizer.py new file mode 100644 index 0000000..192b6f5 --- /dev/null +++ b/sendspin/tui/visualizer.py @@ -0,0 +1,273 @@ +"""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 + +# 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) -> None: + self._spectrum: list[float] = [] + self._spectrum_target: list[float] = [] + self._loudness: float = 0.0 + self._loudness_target: float = 0.0 + 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).""" + 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 loudness is not None: + self._loudness_target = loudness / 65535.0 + + def clear(self) -> None: + """Clear all state immediately.""" + self._spectrum = [] + 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 _step(self) -> None: + """Advance displayed values toward targets.""" + 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 + + # 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) + + @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.""" + return list(self._spectrum) + + @property + def loudness(self) -> float: + """Return current loudness without per-frame decay.""" + return self._loudness + + def get_peaks(self) -> list[float]: + """Return the current peak hold heights (0.0-1.0 per bin).""" + return list(self._peaks) + + +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 or height <= 0: + return [Text(" " * max(0, width)) for _ in range(max(0, height))] + + tip, base = loudness_to_colors(loudness) + + # 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): + start = i * n_bins / width + end = (i + 1) * n_bins / width + start_idx = int(start) + end_idx = min(int(end), n_bins - 1) + + 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_bottom = (height - 1 - row_idx) * _BLOCK_LEVELS + color = row_colors[row_idx] + + for bar_idx, value in enumerate(bars): + level = value * total_levels + if value > 0.0: + level = max(level, 1.0) + fill = level - row_bottom + + # 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: + line.append(" ") + else: + line.append(_BLOCKS[int(fill)], style=color) + + rows.append(line) + + return rows diff --git a/sendspin/visualizer_connector.py b/sendspin/visualizer_connector.py new file mode 100644 index 0000000..29f7ecd --- /dev/null +++ b/sendspin/visualizer_connector.py @@ -0,0 +1,151 @@ +"""Visualizer connector for bridging Sendspin client to the TUI visualizer.""" + +from __future__ import annotations + +import asyncio +import logging +from collections import deque +from collections.abc import Callable +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: + 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: deque[tuple[int, VisualizerFrame]] = deque() + 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_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 = [] + self.reset() + 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._schedule_next() + + 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 + 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 Roles.VISUALIZER.value 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 Roles.VISUALIZER.value 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.popleft() + latest_due = frame + + if latest_due is not None: + self._on_frame(latest_due) + + if self._pending: + self._schedule_next() 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() diff --git a/tests/tui/test_visualizer.py b/tests/tui/test_visualizer.py new file mode 100644 index 0000000..f838108 --- /dev/null +++ b/tests/tui/test_visualizer.py @@ -0,0 +1,121 @@ +"""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() -> 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() -> 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() -> 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() -> None: + 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() -> 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() -> 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() -> None: + state = VisualizerState() + state.update([65535, 65535], loudness=32768) + state.step() + _ = 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 + state.step() + peaks = state.get_peaks() + assert peaks[0] < 0.9 + + +def test_peaks_cleared_on_clear() -> None: + state = VisualizerState() + state.update([65535], loudness=32768) + state.step() + _ = state.get_spectrum() + _ = state.get_peaks() + state.clear() + assert state.get_peaks() == [] + + +# --- render_spectrum tests --- + + +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() -> 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() -> None: + 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 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,