From fa59160c35fb5ba84f936695228da24db39ccc72 Mon Sep 17 00:00:00 2001 From: Willem Vooijs Date: Sun, 25 Oct 2026 15:25:46 -0400 Subject: [PATCH 1/4] Bump aiosendspin to 10.2.1 (Sendspin 1.0) aiosendspin 10 follows the Sendspin 1.0-rc1 spec. Music Assistant moved to it in 2.10.7, so the 9.1.1 pin no longer protects compatibility. - Rename static delay to output delay: --output-delay-ms (with --static-delay-ms kept as a deprecated alias), the output_delay_ms setting (migrated from static_delay_ms on load), and SET_OUTPUT_DELAY - Switch PIN pairing to pairing codes and show the grouped form - Request artwork via artwork_channels with width/height - Accept the send_ahead argument on audio chunks - Declare player volume/mute in client/state - Drop the pitch visualizer type, which the 1.0 client no longer fills - Treat server/state metadata as full state, take repeat/shuffle from the controller object, and clear metadata/color when a role is removed - Document the 1.0 server requirement Closes #298 Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 6 +- README.md | 23 +++-- pyproject.toml | 2 +- sendspin/audio_connector.py | 6 +- sendspin/cli.py | 31 ++++-- sendspin/daemon/daemon.py | 57 +++++------ sendspin/settings.py | 24 +++-- sendspin/tui/app.py | 159 ++++++++++++++----------------- sendspin/tui/keyboard.py | 16 ++-- sendspin/tui/ui.py | 6 +- tests/daemon/test_daemon.py | 18 ++-- tests/test_audio_connector.py | 2 +- tests/test_settings.py | 14 +++ tests/tui/test_metadata_clear.py | 66 ++++++++++++- tests/tui/test_volume_state.py | 22 ++--- uv.lock | 10 +- 16 files changed, 280 insertions(+), 182 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e49c8de..80329f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,10 +46,10 @@ Project documentation, installation instructions, usage guide (including command ### `sendspin/cli.py` Main entry point and orchestrator. Responsibilities: -- **Argument parsing**: Commands (`serve`, `daemon`), flags (`--url`, `--name`, `--id`, `--audio-device`, `--static-delay-ms`) +- **Argument parsing**: Commands (`serve`, `daemon`), flags (`--url`, `--name`, `--id`, `--audio-device`, `--output-delay-ms`) - **Command routing**: Routes to appropriate mode (TUI app, daemon, or server) - **Device enumeration**: Lists audio devices and servers via `audio-devices list` and `servers list` subcommands -- **Backward compatibility**: Handles deprecated `--headless` flag with warning +- **Backward compatibility**: Handles deprecated `--headless` and `--static-delay-ms` flags with warnings ### `sendspin/tui/` (TUI Mode Package) Interactive terminal UI mode with keyboard control. @@ -69,7 +69,7 @@ Keyboard input handling for interactive control. Responsibilities: - **Command parsing**: `CommandHandler` class parses and executes commands - **Media commands**: play, pause, stop, next, previous, shuffle, repeat modes, switch group - **Volume control**: Group volume (`vol+`/`vol-`/`mute`) and player volume (`pvol+`/`pvol-`/`pmute`) -- **Delay adjustment**: Real-time static delay adjustment via `delay` command +- **Delay adjustment**: Real-time output delay adjustment via `delay` command - **Keyboard shortcuts**: Arrow keys (prev/next/volume), space (toggle), m (mute), g (switch group), q (quit) #### `sendspin/tui/ui.py` diff --git a/README.md b/README.md index 64869b6..adce01d 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ Settings are stored in `~/.config/sendspin/`: { "player_volume": 50, "player_muted": false, - "static_delay_ms": 0, + "output_delay_ms": 0, "last_server_url": "ws://192.168.1.100:8927/sendspin", "name": "Living Room", "audio_device": "2", @@ -146,7 +146,7 @@ Settings are stored in `~/.config/sendspin/`: |---------|------|------|-------------| | `player_volume` | integer (0-100) | TUI/daemon | Player output volume percentage | | `player_muted` | boolean | TUI/daemon | Whether the player is muted | -| `static_delay_ms` | float | TUI/daemon | Extra playback delay in milliseconds | +| `output_delay_ms` | float | TUI/daemon | Extra playback delay in milliseconds (`--output-delay-ms`). Older settings files that use `static_delay_ms` are migrated automatically | | `last_server_url` | string | TUI/daemon | Server URL (used as default for `--url`) | | `name` | string | All | Friendly name for client or server (`--name`) | | `audio_device` | string | TUI/daemon | Audio device index, name prefix, or ALSA device name (`--audio-device`) | @@ -177,6 +177,10 @@ By default, the player automatically discovers Sendspin servers on your local ne sendspin --url ws://192.168.1.100:8080/sendspin ``` +The player follows the Sendspin 1.0 spec, so it needs a server built on it, such as +Music Assistant 2.10.7 or newer (or another server on aiosendspin 10). Older servers refuse +the connection. + **List available servers on the network:** ```bash sendspin servers list @@ -186,22 +190,22 @@ sendspin servers list Sendspin connections are end-to-end encrypted. The first time a client connects to a server that requires pairing, the server picks a pairing method and the client displays -a short PIN — enter it on the server to approve the client. Once paired, the client's +a short pairing code — enter it on the server to approve the client. Once paired, the client's credentials are persisted (see `--settings-dir` below), so subsequent connections to the same server don't require pairing again. -- **TUI**: the PIN appears in a "Pairing Required" panel that takes over the screen until +- **TUI**: the pairing code appears in a "Pairing Required" panel that takes over the screen until pairing completes. -- **Daemon**: the PIN is only logged (`Pairing required: enter PIN ...`) — there's no other +- **Daemon**: the pairing code is only logged (`Pairing required: enter pairing code ...`) — there's no other surface for it. You need to be watching the daemon's output at the moment it first connects to a new server. Under systemd, that means `journalctl -u sendspin -f`. For a headless install with unpaired access turned off, plan to watch the log during first setup for each new server. -If a pairing attempt fails (e.g. a mismatched PIN), the client logs the reason and you +If a pairing attempt fails (e.g. a mismatched pairing code), the client logs the reason and you can retry by reconnecting. -Unpaired access is on by default, so servers can play on this client without a PIN once +Unpaired access is on by default, so servers can play on this client without a pairing code once the server approves it (Music Assistant does this automatically). Unpaired connections are still encrypted, but another device on the network could impersonate either side. To require pairing, use `--unpaired-access false` (or `"unpaired_access": false` in the @@ -311,9 +315,11 @@ Because Sendspin cannot read back external device state from the hook, startup v The player supports adjusting playback delay to compensate for audio hardware latency or achieve better synchronization across devices. ```bash -sendspin --static-delay-ms 50 +sendspin --output-delay-ms 50 ``` +`--static-delay-ms` still works as a deprecated alias for `--output-delay-ms`. + > **Note:** A delay of 0ms works well in most cases. If audio is playing slightly too late, a small positive delay (e.g., 50ms) can help compensate for audio hardware latency. On compatible servers, delay can be configured remotely per player, so you shouldn't need to set this locally. ### Daemon Mode @@ -369,7 +375,6 @@ The TUI includes a real-time audio visualizer driven by the `visualizer@v1` role - **Spectrum bars** — frequency magnitude across the range, tinted by overall loudness (and by the album-artwork palette when the server provides one). - **Beats timeline** — a `beats (NNN BPM):` strip with the estimated tempo; downbeats render differently from regular beats. - **Peaks timeline** — a `peaks:` strip of energy onsets (transients like drum hits), independent of the beat grid, with glyph height scaled by onset strength. -- **Pitch** — the perceived musical note (e.g. `A4`) with an arrow pointing at its position on the spectrum, shown whenever a pitch is detected. - **Dominant frequency** — an `f_peak:` readout with an arrow marking the loudest frequency on the spectrum. Lower rows are dropped first on short terminals, keeping the spectrum visible. diff --git a/pyproject.toml b/pyproject.toml index 8f2d3d4..9c4cb1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] dependencies = [ - "aiosendspin[server]~=9.1.1", + "aiosendspin[server]~=10.2.1", "aiosendspin-mpris~=2.1.1", "av>=15.0.0", "numpy>=1.26.0", diff --git a/sendspin/audio_connector.py b/sendspin/audio_connector.py index 84b078f..c7eabac 100644 --- a/sendspin/audio_connector.py +++ b/sendspin/audio_connector.py @@ -512,7 +512,11 @@ def _clear_audio_worker(self) -> None: worker.clear() def _on_audio_chunk( - self, server_timestamp_us: int, audio_data: bytes | bytearray, fmt: AudioFormat + self, + server_timestamp_us: int, + audio_data: bytes | bytearray, + fmt: AudioFormat, + send_ahead_us: int, ) -> None: """Handle incoming audio chunks by enqueueing them to the sync worker.""" worker = self._audio_worker diff --git a/sendspin/cli.py b/sendspin/cli.py index a137797..4426e15 100644 --- a/sendspin/cli.py +++ b/sendspin/cli.py @@ -179,11 +179,17 @@ def _add_player_runtime_options(target: ArgumentTarget, *, suppress_defaults: bo help="Logging level to use (default: INFO)", ) target.add_argument( - "--static-delay-ms", + "--output-delay-ms", type=float, default=default, help="Extra playback delay in milliseconds applied after clock sync", ) + target.add_argument( + "--static-delay-ms", + type=float, + default=default, + help=argparse.SUPPRESS, + ) target.add_argument( "--audio-device", type=str, @@ -413,11 +419,17 @@ def _build_parser() -> argparse.ArgumentParser: help="Logging level to use (default: INFO)", ) daemon_parser.add_argument( - "--static-delay-ms", + "--output-delay-ms", type=float, default=None, help="Extra playback delay in milliseconds applied after clock sync", ) + daemon_parser.add_argument( + "--static-delay-ms", + type=float, + default=None, + help=argparse.SUPPRESS, + ) daemon_parser.add_argument( "--audio-device", type=str, @@ -736,7 +748,7 @@ async def _run_daemon_mode( pairing_store=pairing_store, client_name=client_name, settings=settings, - static_delay_ms=args.static_delay_ms, + output_delay_ms=args.output_delay_ms, listen_port=args.listen_port, use_mpris=args.use_mpris, preferred_format=_resolve_preferred_format(args.audio_format, audio_device), @@ -830,6 +842,13 @@ async def _run_client_mode(args: argparse.Namespace) -> int: print("Routing to daemon mode...\n") args.command = "daemon" + # Handle deprecated --static-delay-ms, renamed to --output-delay-ms by the spec. + static_delay_ms = getattr(args, "static_delay_ms", None) + if static_delay_ms is not None: + print("Warning: --static-delay-ms is deprecated. Use --output-delay-ms instead.") + if getattr(args, "output_delay_ms", None) is None: + args.output_delay_ms = static_delay_ms + is_daemon = args.command == "daemon" mode: Literal["tui", "daemon"] = "daemon" if is_daemon else "tui" settings_dir = getattr(args, "settings_dir", None) @@ -846,8 +865,8 @@ async def _run_client_mode(args: argparse.Namespace) -> int: args.name = settings.name if args.audio_device is None: args.audio_device = settings.audio_device - if args.static_delay_ms is None and settings.static_delay_ms != 0.0: - args.static_delay_ms = settings.static_delay_ms + if args.output_delay_ms is None and settings.output_delay_ms != 0.0: + args.output_delay_ms = settings.output_delay_ms if args.log_level is None: args.log_level = settings.log_level or "INFO" if is_daemon and getattr(args, "listen_port", None) is None: @@ -951,7 +970,7 @@ async def _run_client_mode(args: argparse.Namespace) -> int: pairing_store=pairing_store, client_name=client_name, settings=settings, - static_delay_ms=args.static_delay_ms, + output_delay_ms=args.output_delay_ms, use_mpris=args.use_mpris, preferred_format=_resolve_preferred_format(args.audio_format, audio_device), volume_controller=volume_controller, diff --git a/sendspin/daemon/daemon.py b/sendspin/daemon/daemon.py index 640be74..052c083 100644 --- a/sendspin/daemon/daemon.py +++ b/sendspin/daemon/daemon.py @@ -49,7 +49,7 @@ class DaemonArgs: client_name: str settings: ClientSettings url: str | None = None - static_delay_ms: float | None = None + output_delay_ms: float | None = None listen_port: int = 8928 use_mpris: bool = True preferred_format: SupportedAudioFormat | None = None @@ -77,11 +77,11 @@ def __init__(self, args: DaemonArgs) -> None: self._audio_handler: AudioStreamHandler | None = None self._settings = args.settings self._mpris: SendspinMpris | None = None - # Currently-applied static delay in milliseconds, mirroring - # `SendspinClient.static_delay_ms`. Tracked separately from settings + # Currently-applied output delay in milliseconds, mirroring + # `SendspinClient.output_delay_ms`. Tracked separately from settings # because CLI overrides aren't persisted to settings, so - # `settings.static_delay_ms` can lag the value actually given to the client. - self._static_delay_ms: float = 0.0 + # `settings.output_delay_ms` can lag the value actually given to the client. + self._output_delay_ms: float = 0.0 self._connection_lock: asyncio.Lock | None = None self._server_url: str | None = None self._admission_timeout_logged: bool = False @@ -114,25 +114,28 @@ def _create_client(self) -> SendspinClient: player_support=ClientHelloPlayerSupport( supported_formats=supported_formats, buffer_capacity=32_000_000, - supported_commands=[PlayerCommand.VOLUME, PlayerCommand.MUTE], ), - static_delay_ms=self._static_delay_ms, - state_supported_commands=[PlayerCommand.SET_STATIC_DELAY], + output_delay_ms=self._output_delay_ms, + state_supported_commands=[ + PlayerCommand.VOLUME, + PlayerCommand.MUTE, + PlayerCommand.SET_OUTPUT_DELAY, + ], initial_volume=self._audio_handler.volume, initial_muted=self._audio_handler.muted, ) def _build_pairing_support(self) -> PairingSupport: - """Build pairing-PIN out-channels for headless (log-based) display.""" + """Build pairing-code out-channels for headless (log-based) display.""" return PairingSupport( - pin_display=self._show_pairing_code, - offer_static_pin=True, + pairing_code_display=self._show_pairing_code, + offer_static_pairing_code=True, ) - async def _show_pairing_code(self, code: str | None) -> None: - """Log (or clear) the derived dynamic pairing PIN.""" - if code is not None: - logger.info("Pairing required: enter PIN %s on the server.", code) + async def _show_pairing_code(self, pairing_code: str | None, *, grouped: str | None) -> None: + """Log (or clear) the derived dynamic pairing code.""" + if grouped is not None: + logger.info("Pairing required: enter pairing code %s on the server.", grouped) def _handle_pairing_abort(self, reason: PairAbortReason) -> None: """Log a non-closing pairing abort (e.g. a mismatched code).""" @@ -156,13 +159,13 @@ def signal_handler() -> None: loop.add_signal_handler(signal.SIGINT, signal_handler) loop.add_signal_handler(signal.SIGTERM, signal_handler) - # CLI arg overrides settings for static delay + # CLI arg overrides settings for output delay delay = ( - self._args.static_delay_ms - if self._args.static_delay_ms is not None - else self._settings.static_delay_ms + self._args.output_delay_ms + if self._args.output_delay_ms is not None + else self._settings.output_delay_ms ) - self._static_delay_ms = max(0.0, min(5000.0, delay)) + self._output_delay_ms = max(0.0, min(5000.0, delay)) self._audio_handler = AudioStreamHandler( audio_device=self._args.audio_device, @@ -489,19 +492,19 @@ def _handle_server_command(self, payload: ServerCommandPayload) -> None: self._audio_handler.set_volume(self._audio_handler.volume, muted=player_cmd.mute) logger.info("Server %s player", "muted" if player_cmd.mute else "unmuted") elif ( - player_cmd.command == PlayerCommand.SET_STATIC_DELAY - and player_cmd.static_delay_ms is not None + player_cmd.command == PlayerCommand.SET_OUTPUT_DELAY + and player_cmd.output_delay_ms is not None ): # Client library already applied the delay change; # notify audio worker so sync correction adjusts timing gradually assert self._client is not None - new_delay_ms = self._client.static_delay_ms - delta_us = int((new_delay_ms - self._static_delay_ms) * 1000) + new_delay_ms = self._client.output_delay_ms + delta_us = int((new_delay_ms - self._output_delay_ms) * 1000) if delta_us != 0: self._audio_handler.notify_delay_change(delta_us) - self._static_delay_ms = new_delay_ms - self._settings.update(static_delay_ms=new_delay_ms) - logger.info("Server set delay: %dms", player_cmd.static_delay_ms) + self._output_delay_ms = new_delay_ms + self._settings.update(output_delay_ms=new_delay_ms) + logger.info("Server set delay: %dms", player_cmd.output_delay_ms) def _handle_format_change( self, codec: str | None, sample_rate: int, bit_depth: int, channels: int diff --git a/sendspin/settings.py b/sendspin/settings.py index 582c056..4264bbf 100644 --- a/sendspin/settings.py +++ b/sendspin/settings.py @@ -123,7 +123,7 @@ class ClientSettings(BaseSettings): player_volume: int = 25 player_muted: bool = False - static_delay_ms: float = 0.0 + output_delay_ms: float = 0.0 last_server_url: str | None = None audio_device: str | None = None use_mpris: bool = True @@ -148,7 +148,7 @@ def update( *, player_volume: int | None = None, player_muted: bool | None = None, - static_delay_ms: float | None = None, + output_delay_ms: float | None = None, last_server_url: str | None = None, name: str | None = None, audio_device: str | None = None, @@ -180,7 +180,7 @@ def update( self._update_fields( { "player_muted": player_muted, - "static_delay_ms": static_delay_ms, + "output_delay_ms": output_delay_ms, "last_server_url": last_server_url, "name": name, "audio_device": audio_device, @@ -210,6 +210,7 @@ def _load(self) -> bool: logger.debug("Settings file does not exist: %s", self._settings_file) return False + needs_save = False try: data = json.loads(self._settings_file.read_text()) # Update fields from loaded data @@ -218,12 +219,17 @@ def _load(self) -> bool: self.listen_port = data.get("listen_port") self.player_volume = data.get("player_volume", 25) self.player_muted = data.get("player_muted", False) - self.static_delay_ms = data.get("static_delay_ms", 0.0) + # Settings saved before the spec rename store the delay as static_delay_ms. + if "output_delay_ms" in data: + self.output_delay_ms = data["output_delay_ms"] + elif "static_delay_ms" in data: + self.output_delay_ms = data["static_delay_ms"] + needs_save = True # Clamp to valid range; also handles old negative sign convention. - if self.static_delay_ms < 0: - self.static_delay_ms = min(5000.0, -self.static_delay_ms) - elif self.static_delay_ms > 5000: - self.static_delay_ms = 5000.0 + if self.output_delay_ms < 0: + self.output_delay_ms = min(5000.0, -self.output_delay_ms) + elif self.output_delay_ms > 5000: + self.output_delay_ms = 5000.0 self.last_server_url = data.get("last_server_url") self.audio_device = data.get("audio_device") self.use_mpris = data.get("use_mpris", True) @@ -247,7 +253,7 @@ def _load(self) -> bool: ) except (json.JSONDecodeError, OSError) as e: logger.warning("Failed to load settings from %s: %s", self._settings_file, e) - return False + return needs_save @dataclass diff --git a/sendspin/tui/app.py b/sendspin/tui/app.py index 7a3dc32..15b9e0f 100644 --- a/sendspin/tui/app.py +++ b/sendspin/tui/app.py @@ -19,7 +19,7 @@ from aiohttp import ClientError from aiosendspin.client import PairingSupport, SendspinClient -from aiosendspin.models.artwork import ArtworkChannel, ClientHelloArtworkSupport +from aiosendspin.models.artwork import ArtworkChannel from aiosendspin.models.core import ( GroupUpdateServerPayload, ServerCommandPayload, @@ -99,44 +99,23 @@ class AppState: shuffle: bool | None = None def update_metadata(self, metadata: SessionUpdateMetadata) -> bool: - """Merge new metadata into the state and report if anything changed.""" - changed = False - - # Update simple metadata fields - for attr in ("title", "artist", "album"): - value = getattr(metadata, attr) - if not isinstance(value, UndefinedField) and getattr(self, attr) != value: - setattr(self, attr, value) - changed = True + """Replace the metadata state and report if anything changed. - # Update repeat and shuffle - for attr in ("repeat_mode", "shuffle"): - # metadata uses "repeat" for the field name, state uses "repeat_mode" - meta_attr = "repeat" if attr == "repeat_mode" else attr - value = getattr(metadata, meta_attr) - if not isinstance(value, UndefinedField) and getattr(self, attr) != value: + The metadata object carries the full state, so an omitted field clears it. + """ + progress = metadata.progress + new_values = { + "title": metadata.title, + "artist": metadata.artist, + "album": metadata.album, + "track_progress": progress.track_progress if progress is not None else None, + "track_duration": progress.track_duration if progress is not None else None, + } + changed = False + for attr, value in new_values.items(): + if getattr(self, attr) != value: setattr(self, attr, value) changed = True - - # Update progress fields from nested progress object - if isinstance(metadata.progress, UndefinedField): - return changed - - if metadata.progress is None: - # Clear progress fields - if self.track_progress is not None or self.track_duration is not None: - self.track_progress = None - self.track_duration = None - changed = True - else: - # Update from nested progress object - if self.track_progress != metadata.progress.track_progress: - self.track_progress = metadata.progress.track_progress - changed = True - if self.track_duration != metadata.progress.track_duration: - self.track_duration = metadata.progress.track_duration - changed = True - return changed def clear_metadata(self) -> bool: @@ -251,7 +230,7 @@ class AppArgs: settings: ClientSettings url: str | None = None url_from_settings: bool = False - static_delay_ms: float | None = None + output_delay_ms: float | None = None use_mpris: bool = True preferred_format: SupportedAudioFormat | None = None volume_controller: VolumeController | None = None @@ -287,10 +266,10 @@ def __init__(self, args: AppArgs) -> None: self._visualizer_enabled: bool = args.settings.visualizer # Probe terminal graphics support before Rich Live takes the tty. self._supports_artwork: bool = detect_artwork_support() - # Currently-applied static delay in milliseconds, mirroring - # `SendspinClient.static_delay_ms`. Tracked separately from settings + # Currently-applied output delay in milliseconds, mirroring + # `SendspinClient.output_delay_ms`. Tracked separately from settings # because CLI overrides aren't persisted to settings, so - # `settings.static_delay_ms` can lag the value actually given to the client. + # `settings.output_delay_ms` can lag the value actually given to the client. self._applied_delay_ms: float = 0.0 interfaces = [args.interface] if args.interface else None self._discovery = ServiceDiscovery(interfaces=interfaces) @@ -300,25 +279,23 @@ def __init__(self, args: AppArgs) -> None: self._listener_unsubscribes: list[Callable[[], None]] = [] @staticmethod - def _build_artwork_support() -> ClientHelloArtworkSupport: - """Build artwork support payload for client/hello (artwork@v1).""" - return ClientHelloArtworkSupport( - channels=[ - ArtworkChannel( - source=ArtworkSource.ALBUM, - format=PictureFormat.PNG, - media_width=128, - media_height=128, - ), - ], - ) + def _build_artwork_channels() -> list[ArtworkChannel]: + """Build the artwork channels requested via client/state (artwork@v1).""" + return [ + ArtworkChannel( + source=ArtworkSource.ALBUM, + format=PictureFormat.PNG, + width=128, + height=128, + ), + ] @staticmethod def _build_visualizer_support() -> ClientHelloVisualizerSupport: """Build visualizer support payload for client/hello (visualizer@v1).""" return ClientHelloVisualizerSupport( buffer_capacity=65536, - types=["loudness", "spectrum", "beat", "peak", "f_peak", "pitch"], + types=["loudness", "spectrum", "beat", "peak", "f_peak"], rate_max=30, spectrum=ClientHelloVisualizerSpectrum( n_disp_bins=SPECTRUM_N_BINS, @@ -337,9 +314,9 @@ def _create_client(self) -> SendspinClient: visualizer_support = self._build_visualizer_support() roles.append(Roles.VISUALIZER) - artwork_support: ClientHelloArtworkSupport | None = None + artwork_channels: list[ArtworkChannel] | None = None if self._supports_artwork: - artwork_support = self._build_artwork_support() + artwork_channels = self._build_artwork_channels() roles.append(Roles.ARTWORK) assert self._audio_handler is not None @@ -357,27 +334,30 @@ def _create_client(self) -> SendspinClient: player_support=ClientHelloPlayerSupport( supported_formats=self._supported_formats, buffer_capacity=32_000_000, - supported_commands=[PlayerCommand.VOLUME, PlayerCommand.MUTE], ), visualizer_support=visualizer_support, - artwork_support=artwork_support, - static_delay_ms=self._applied_delay_ms, - state_supported_commands=[PlayerCommand.SET_STATIC_DELAY], + artwork_channels=artwork_channels, + output_delay_ms=self._applied_delay_ms, + state_supported_commands=[ + PlayerCommand.VOLUME, + PlayerCommand.MUTE, + PlayerCommand.SET_OUTPUT_DELAY, + ], initial_volume=self._audio_handler.volume, initial_muted=self._audio_handler.muted, ) def _build_pairing_support(self) -> PairingSupport: - """Build pairing-PIN out-channels wired into the Rich UI.""" + """Build pairing-code out-channels wired into the Rich UI.""" return PairingSupport( - pin_display=self._show_pairing_code, - offer_static_pin=True, + pairing_code_display=self._show_pairing_code, + offer_static_pairing_code=True, ) - async def _show_pairing_code(self, code: str | None) -> None: - """Render (or clear) the derived dynamic pairing PIN in the UI.""" + async def _show_pairing_code(self, pairing_code: str | None, *, grouped: str | None) -> None: + """Render (or clear) the derived dynamic pairing code in the UI.""" if self._ui is not None: - self._ui.show_pairing_code(code) + self._ui.show_pairing_code(grouped) def _attach_client(self) -> None: """Attach listeners, audio handler, visualizer, and MPRIS to the current client.""" @@ -484,11 +464,11 @@ def request_shutdown() -> None: main_task.cancel() try: - # CLI arg overrides settings for static delay + # CLI arg overrides settings for output delay delay = ( - args.static_delay_ms - if args.static_delay_ms is not None - else self._settings.static_delay_ms + args.output_delay_ms + if args.output_delay_ms is not None + else self._settings.output_delay_ms ) self._applied_delay_ms = max(0.0, min(5000.0, delay)) @@ -813,18 +793,17 @@ async def _on_server_selected(self) -> None: assert self._client is not None await self._client.disconnect() - def _handle_metadata_update(self, payload: ServerStatePayload) -> None: + def _handle_metadata_update(self, payload: ServerStatePayload | None) -> None: """Handle server/state messages with metadata.""" assert self._ui is not None state = self._state ui = self._ui - if isinstance(payload.metadata, UndefinedField): - return - if payload.metadata is None: - # A bare ``metadata: null`` is a spec-legal clear -- an absent field - # is UndefinedField and handled above -- so drop the stale - # now-playing fields instead of leaving the previous track on screen. + if payload is None: + # The metadata role was removed, so drop the stale now-playing + # fields instead of leaving the previous track on screen. changed = state.clear_metadata() + elif isinstance(payload.metadata, UndefinedField): + return else: changed = state.update_metadata(payload.metadata) if not changed: @@ -837,7 +816,6 @@ def _handle_metadata_update(self, payload: ServerStatePayload) -> None: album=state.album, ) ui.set_progress(state.track_progress, state.track_duration) - ui.set_repeat_shuffle(state.repeat_mode, state.shuffle) ui.add_event(state.describe()) def _clear_visualizer_timelines(self) -> None: @@ -850,9 +828,13 @@ def _clear_visualizer_timelines(self) -> None: self._ui.clear_beats() self._ui.clear_peaks() - def _handle_color_update(self, payload: ServerStatePayload) -> None: + def _handle_color_update(self, payload: ServerStatePayload | None) -> None: """Forward a color@v1 palette payload to the UI.""" assert self._ui is not None + if payload is None: + # The color role was removed, so revert to the unthemed palette. + self._ui.update_palette(None) + return if isinstance(payload.color, UndefinedField): return self._ui.update_palette(payload.color) @@ -886,12 +868,12 @@ def _handle_group_update(self, payload: GroupUpdateServerPayload) -> None: if changed and payload.playback_state != PlaybackStateType.PLAYING: self._clear_visualizer_timelines() - def _handle_server_state(self, payload: ServerStatePayload) -> None: + def _handle_server_state(self, payload: ServerStatePayload | None) -> None: """Handle server/state messages with controller state.""" assert self._ui is not None state = self._state ui = self._ui - if payload.controller is None or isinstance(payload.controller, UndefinedField): + if payload is None or isinstance(payload.controller, UndefinedField): return controller = payload.controller @@ -910,6 +892,11 @@ def _handle_server_state(self, payload: ServerStatePayload) -> None: if volume_changed or mute_changed: ui.set_volume(state.volume, muted=state.muted) + if controller.repeat != state.repeat_mode or controller.shuffle != state.shuffle: + state.repeat_mode = controller.repeat + state.shuffle = controller.shuffle + ui.set_repeat_shuffle(state.repeat_mode, state.shuffle) + def _handle_server_command(self, payload: ServerCommandPayload) -> None: """Handle server/command messages for player volume/mute control.""" if payload.player is None: @@ -928,21 +915,21 @@ def _handle_server_command(self, payload: ServerCommandPayload) -> None: "Server muted player" if player_cmd.mute else "Server unmuted player" ) elif ( - player_cmd.command == PlayerCommand.SET_STATIC_DELAY - and player_cmd.static_delay_ms is not None + player_cmd.command == PlayerCommand.SET_OUTPUT_DELAY + and player_cmd.output_delay_ms is not None ): # Client library already applied the delay change; # notify audio worker so sync correction adjusts timing gradually assert self._client is not None assert self._audio_handler is not None - new_delay_ms = self._client.static_delay_ms + new_delay_ms = self._client.output_delay_ms delta_us = int((new_delay_ms - self._applied_delay_ms) * 1000) if delta_us != 0: self._audio_handler.notify_delay_change(delta_us) self._applied_delay_ms = new_delay_ms self._ui.set_delay(new_delay_ms) - self._settings.update(static_delay_ms=new_delay_ms) - self._ui.add_event(f"Server set delay: {player_cmd.static_delay_ms}ms") + self._settings.update(output_delay_ms=new_delay_ms) + self._ui.add_event(f"Server set delay: {player_cmd.output_delay_ms}ms") def _handle_format_change( self, codec: str | None, sample_rate: int, bit_depth: int, channels: int @@ -952,7 +939,7 @@ def _handle_format_change( self._ui.set_audio_format(codec, sample_rate, bit_depth, channels) def _set_applied_delay(self, delay_ms: float) -> None: - """Mirror the locally-applied static delay so SET_STATIC_DELAY deltas stay correct.""" + """Mirror the locally-applied output delay so SET_OUTPUT_DELAY deltas stay correct.""" self._applied_delay_ms = delay_ms async def _toggle_visualizer(self) -> None: diff --git a/sendspin/tui/keyboard.py b/sendspin/tui/keyboard.py index 35c8c60..99ac8b2 100644 --- a/sendspin/tui/keyboard.py +++ b/sendspin/tui/keyboard.py @@ -110,20 +110,20 @@ async def toggle_shuffle(self) -> None: await self.send_media_command(MediaCommand.SHUFFLE) async def adjust_delay(self, delta: float) -> None: - """Adjust static delay by delta milliseconds.""" + """Adjust output delay by delta milliseconds.""" client = self._get_client() if client is None: return - old_delay = client.static_delay_ms + old_delay = client.output_delay_ms new_delay = max(0, min(5000, old_delay + delta)) - client.set_static_delay_ms(new_delay) - actual_delta_us = int((client.static_delay_ms - old_delay) * 1000) + client.set_output_delay_ms(new_delay) + actual_delta_us = int((client.output_delay_ms - old_delay) * 1000) if actual_delta_us != 0: self._audio_handler.notify_delay_change(actual_delta_us) - self._ui.set_delay(client.static_delay_ms) - self._settings.update(static_delay_ms=client.static_delay_ms) + self._ui.set_delay(client.output_delay_ms) + self._settings.update(output_delay_ms=client.output_delay_ms) if self._on_delay_changed is not None: - self._on_delay_changed(client.static_delay_ms) + self._on_delay_changed(client.output_delay_ms) self._audio_handler.send_player_volume() def close_server_selector(self) -> None: @@ -155,7 +155,7 @@ async def keyboard_loop( 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. - on_delay_changed: Optional callback invoked with the new static delay + on_delay_changed: Optional callback invoked with the new output delay (milliseconds) after a local adjust, so the app can keep its applied-delay tracker in sync. """ diff --git a/sendspin/tui/ui.py b/sendspin/tui/ui.py index 37a8cee..f4e6239 100644 --- a/sendspin/tui/ui.py +++ b/sendspin/tui/ui.py @@ -154,7 +154,7 @@ class UIState: highlighted_shortcut: str | None = None highlight_time: float = 0.0 - # Pairing: the derived dynamic PIN, shown while a pairing exchange is in + # Pairing: the derived dynamic pairing code, shown while a pairing exchange is in # progress. None when not pairing. pairing_code: str | None = None @@ -688,7 +688,7 @@ def _build_server_selector_panel(self) -> Panel: return self._make_panel(content, title="Select Server", default_border="cyan") def _build_pairing_panel(self) -> Panel: - """Build the pairing panel: the derived dynamic PIN.""" + """Build the pairing panel: the derived dynamic pairing code.""" content = Table.grid() content.add_column(justify="center") @@ -1452,7 +1452,7 @@ def is_server_selector_visible(self) -> bool: return self._state.show_server_selector def show_pairing_code(self, code: str | None) -> None: - """Show (or clear, on ``None``) the derived dynamic pairing PIN.""" + """Show (or clear, on ``None``) the derived dynamic pairing code.""" self._state.pairing_code = code self.refresh() diff --git a/tests/daemon/test_daemon.py b/tests/daemon/test_daemon.py index 495d7f4..01a59a9 100644 --- a/tests/daemon/test_daemon.py +++ b/tests/daemon/test_daemon.py @@ -70,26 +70,26 @@ def test_mute_command_uses_audio_handler_volume_state_for_external_volume(tmp_pa assert daemon._audio_handler.calls == [(53, True)] -def test_set_static_delay_uses_applied_tracker_for_delta(tmp_path: Path) -> None: - """Sync delta is computed from `_static_delay_ms`, not stale settings. +def test_set_output_delay_uses_applied_tracker_for_delta(tmp_path: Path) -> None: + """Sync delta is computed from `_output_delay_ms`, not stale settings. Reproduces the CLI-override case: settings stays at 0 while the client was - initialized to 500 from `--static-delay-ms`. A server-initiated delay change + initialized to 500 from `--output-delay-ms`. A server-initiated delay change to 200 must produce delta = -300ms (200 - 500), not -200ms (200 - 0). """ daemon = _make_daemon(tmp_path, settings_volume=25, settings_muted=False) daemon._audio_handler = _FakeAudioHandler(volume=25, muted=False) - daemon._static_delay_ms = 500.0 + daemon._output_delay_ms = 500.0 # aiosendspin auto-applies before the callback fires, so the client already # reports the new value. - daemon._client = SimpleNamespace(static_delay_ms=200.0) # type: ignore[assignment] + daemon._client = SimpleNamespace(output_delay_ms=200.0) # type: ignore[assignment] payload = SimpleNamespace( player=SimpleNamespace( - command=PlayerCommand.SET_STATIC_DELAY, + command=PlayerCommand.SET_OUTPUT_DELAY, volume=None, mute=None, - static_delay_ms=200, + output_delay_ms=200, ) ) @@ -100,5 +100,5 @@ async def run() -> None: asyncio.run(run()) assert daemon._audio_handler.delay_changes == [-300_000] - assert daemon._static_delay_ms == 200.0 - assert daemon._settings.static_delay_ms == 200.0 + assert daemon._output_delay_ms == 200.0 + assert daemon._settings.output_delay_ms == 200.0 diff --git a/tests/test_audio_connector.py b/tests/test_audio_connector.py index 055c67b..e69b66c 100644 --- a/tests/test_audio_connector.py +++ b/tests/test_audio_connector.py @@ -166,7 +166,7 @@ async def exercise() -> None: assert restarted_worker.volume == 37 assert restarted_worker.muted is True - handler._on_audio_chunk(123_456, b"payload", fmt) + handler._on_audio_chunk(123_456, b"payload", fmt, 0) assert restarted_worker.submitted == [(123_456, b"payload", fmt)] diff --git a/tests/test_settings.py b/tests/test_settings.py index d18ae77..0d100a9 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -37,3 +37,17 @@ async def test_unpaired_access_from_settings_file( settings = await get_client_settings("daemon", str(tmp_path)) assert settings.unpaired_access is expected + + +async def test_legacy_static_delay_migrates_to_output_delay(tmp_path: Path) -> None: + """Settings saved before the spec rename keep their delay and are rewritten.""" + path = tmp_path / "settings-daemon.json" + path.write_text(json.dumps({"static_delay_ms": 120.0})) + + settings = await get_client_settings("daemon", str(tmp_path)) + assert settings.output_delay_ms == 120.0 + + await settings.flush() + saved = json.loads(path.read_text()) + assert saved["output_delay_ms"] == 120.0 + assert "static_delay_ms" not in saved diff --git a/tests/tui/test_metadata_clear.py b/tests/tui/test_metadata_clear.py index becfb25..1a0706c 100644 --- a/tests/tui/test_metadata_clear.py +++ b/tests/tui/test_metadata_clear.py @@ -6,6 +6,11 @@ from types import SimpleNamespace from typing import Any +from aiosendspin.models.controller import ControllerStatePayload +from aiosendspin.models.core import ServerStatePayload +from aiosendspin.models.metadata import Progress, SessionUpdateMetadata +from aiosendspin.models.types import MediaCommand, RepeatMode + from sendspin.tui.app import AppState, SendspinApp @@ -42,12 +47,67 @@ def test_app_state_clear_metadata_resets_now_playing() -> None: assert state.clear_metadata() is False -def test_handle_metadata_update_clears_on_null_payload() -> None: - """A spec-legal metadata clear must not leave stale now-playing text behind.""" +def test_handle_metadata_update_clears_on_removed_role() -> None: + """A removed metadata role must not leave stale now-playing text behind.""" state = AppState(title="Track", artist="Artist", album="Album") - SendspinApp._handle_metadata_update(_app_with(state), SimpleNamespace(metadata=None)) + SendspinApp._handle_metadata_update(_app_with(state), None) assert state.title is None assert state.artist is None assert state.album is None + + +def test_update_metadata_clears_omitted_fields() -> None: + """Metadata is full state: a field the server leaves out is cleared.""" + state = AppState( + title="Old", + artist="Artist", + album="Album", + track_progress=1000, + track_duration=5000, + ) + + assert state.update_metadata(SessionUpdateMetadata(timestamp=0, title="New")) is True + assert state.title == "New" + assert state.artist is None + assert state.album is None + assert state.track_progress is None + assert state.track_duration is None + # Re-applying the same state reports no change. + assert state.update_metadata(SessionUpdateMetadata(timestamp=1, title="New")) is False + + +def test_update_metadata_sets_progress() -> None: + state = AppState() + metadata = SessionUpdateMetadata( + timestamp=0, + title="Track", + progress=Progress(track_progress=1000, track_duration=5000, playback_speed=1000), + ) + + assert state.update_metadata(metadata) is True + assert state.track_progress == 1000 + assert state.track_duration == 5000 + + +def test_handle_server_state_takes_repeat_and_shuffle_from_controller() -> None: + """Repeat and shuffle moved from the metadata object to the controller object.""" + state = AppState() + shown: list[tuple[RepeatMode | None, bool | None]] = [] + app = _app_with(state) + app._ui.set_volume = lambda *args, **kwargs: None + app._ui.set_repeat_shuffle = lambda repeat, shuffle: shown.append((repeat, shuffle)) + controller = ControllerStatePayload( + supported_commands=[MediaCommand.PLAY], + volume=50, + muted=False, + repeat=RepeatMode.ALL, + shuffle=True, + ) + + SendspinApp._handle_server_state(app, ServerStatePayload(controller=controller)) + + assert state.repeat_mode == RepeatMode.ALL + assert state.shuffle is True + assert shown == [(RepeatMode.ALL, True)] diff --git a/tests/tui/test_volume_state.py b/tests/tui/test_volume_state.py index a3a6a65..594ceab 100644 --- a/tests/tui/test_volume_state.py +++ b/tests/tui/test_volume_state.py @@ -90,11 +90,11 @@ def test_tui_mute_command_uses_audio_handler_volume_state(tmp_path: Path) -> Non assert app._audio_handler.calls == [(53, True)] -def test_tui_set_static_delay_uses_applied_tracker_for_delta(tmp_path: Path) -> None: +def test_tui_set_output_delay_uses_applied_tracker_for_delta(tmp_path: Path) -> None: """Sync delta is computed from `_applied_delay_ms`, not stale settings. Reproduces the CLI-override case: settings stays at 0 while the client was - initialized to 500 from `--static-delay-ms`. A server-initiated delay change + initialized to 500 from `--output-delay-ms`. A server-initiated delay change to 200 must produce delta = -300ms (200 - 500), not -200ms (200 - 0). """ app = _make_app(tmp_path) @@ -103,14 +103,14 @@ def test_tui_set_static_delay_uses_applied_tracker_for_delta(tmp_path: Path) -> app._applied_delay_ms = 500.0 # aiosendspin auto-applies before the callback fires, so the client already # reports the new value. - app._client = SimpleNamespace(static_delay_ms=200.0) + app._client = SimpleNamespace(output_delay_ms=200.0) payload = SimpleNamespace( player=SimpleNamespace( - command=PlayerCommand.SET_STATIC_DELAY, + command=PlayerCommand.SET_OUTPUT_DELAY, volume=None, mute=None, - static_delay_ms=200, + output_delay_ms=200, ) ) @@ -123,7 +123,7 @@ async def run() -> None: assert app._audio_handler.delay_changes == [-300_000] assert app._applied_delay_ms == 200.0 assert app._ui.delays == [200.0] - assert app._settings.static_delay_ms == 200.0 + assert app._settings.output_delay_ms == 200.0 def test_keyboard_volume_change_uses_audio_handler_state(tmp_path: Path) -> None: @@ -169,12 +169,12 @@ def test_keyboard_adjust_delay_notifies_app_tracker(tmp_path: Path) -> None: audio_handler = _FakeAudioHandler(volume=50, muted=False) ui = _FakeUI() captured: list[float] = [] - client = SimpleNamespace(static_delay_ms=0.0) + client = SimpleNamespace(output_delay_ms=0.0) - def set_static_delay_ms(value: float) -> None: - client.static_delay_ms = max(0.0, min(5000.0, value)) + def set_output_delay_ms(value: float) -> None: + client.output_delay_ms = max(0.0, min(5000.0, value)) - client.set_static_delay_ms = set_static_delay_ms + client.set_output_delay_ms = set_output_delay_ms handler = CommandHandler( get_client=lambda: client, @@ -187,6 +187,6 @@ def set_static_delay_ms(value: float) -> None: asyncio.run(handler.adjust_delay(50)) - assert client.static_delay_ms == 50.0 + assert client.output_delay_ms == 50.0 assert audio_handler.delay_changes == [50_000] assert captured == [50.0] diff --git a/uv.lock b/uv.lock index 0848e1d..263e17c 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" [[package]] @@ -98,7 +98,7 @@ wheels = [ [[package]] name = "aiosendspin" -version = "9.1.1" +version = "10.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -109,9 +109,9 @@ dependencies = [ { name = "orjson" }, { name = "zeroconf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/73/f0/03f798813a0e09f4e1565691d2e5138f0750c98b36d93f522d06c44efe66/aiosendspin-9.1.1.tar.gz", hash = "sha256:1e9a33ec5b6dcbc16b4f03c07586195beaa80eb2e5ba4254b97979ae0e0ec4b7", size = 251694, upload-time = "2026-08-25T15:28:43.958Z" } +sdist = { url = "https://files.pythonhosted.org/packages/59/74/20836f6e32e902c72796b6731a7da86f062c64a760b93b6f62f9cc3ff873/aiosendspin-10.2.1.tar.gz", hash = "sha256:94ef9214e7b14c52c9d792b87975ec7638be3c62fcfb61aca9dc9c9559563c55", size = 348450, upload-time = "2026-10-19T15:21:47.207Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/c8/e6bb6ece16ef74a3867543675a153cb09b10eb907025e8d77ac4db547bc4/aiosendspin-9.1.1-py3-none-any.whl", hash = "sha256:cb7227f70a8c6cfea450fb92ad26ed6fe3d86e883620700e2cde9372ad8c995b", size = 278171, upload-time = "2026-08-25T15:28:42.58Z" }, + { url = "https://files.pythonhosted.org/packages/ab/97/0b7344c0b7d446a2228fb90ba380fd08af7f9b96941988608723f849ced1/aiosendspin-10.2.1-py3-none-any.whl", hash = "sha256:22aeeaa53bd383a9cd026a5b505fc3913a021bbc9d10011684e256e0691a44e1", size = 356651, upload-time = "2026-10-19T15:21:45.718Z" }, ] [package.optional-dependencies] @@ -1382,7 +1382,7 @@ test = [ [package.metadata] requires-dist = [ - { name = "aiosendspin", extras = ["server"], specifier = "~=9.1.1" }, + { name = "aiosendspin", extras = ["server"], specifier = "~=10.2.1" }, { name = "aiosendspin-mpris", specifier = "~=2.1.1" }, { name = "av", specifier = ">=15.0.0" }, { name = "codespell", marker = "extra == 'test'", specifier = "==2.4.1" }, From c29c11389da28b2ec126f050bd2083683a7bfe9f Mon Sep 17 00:00:00 2001 From: Willem Vooijs Date: Sun, 25 Oct 2026 15:28:08 -0400 Subject: [PATCH 2/4] Remove the pitch visualizer readout The Sendspin 1.0 visualizer role has no pitch type, and the aiosendspin 10 client no longer fills pitch on frames, so the TUI's pitch arrow and note readout could never show. Drop it along with the MIDI note helpers; the f_peak cursor and footer stay. Co-Authored-By: Claude Opus 5.5 --- sendspin/tui/app.py | 1 - sendspin/tui/ui.py | 59 ++++++++------------------- sendspin/tui/visualizer.py | 39 ++---------------- sendspin/visualizer_connector.py | 7 +--- tests/test_visualizer_connector.py | 8 ++-- tests/tui/test_visualizer.py | 65 ++++++------------------------ 6 files changed, 37 insertions(+), 142 deletions(-) diff --git a/sendspin/tui/app.py b/sendspin/tui/app.py index 15b9e0f..fe9e3ef 100644 --- a/sendspin/tui/app.py +++ b/sendspin/tui/app.py @@ -1004,7 +1004,6 @@ def _handle_visualizer_frame(self, frame: VisualizerFrame) -> None: self._ui.set_visualizer_frame( frame.spectrum, frame.loudness, - frame.pitch_midi_q88, frame.f_peak_freq, ) diff --git a/sendspin/tui/ui.py b/sendspin/tui/ui.py index f4e6239..09ec5a5 100644 --- a/sendspin/tui/ui.py +++ b/sendspin/tui/ui.py @@ -837,7 +837,7 @@ def _build_visualizer_rows(self, height: int) -> list[Text]: """Build the visualizer as raw Text rows, totaling `height`. Stacks the peak and beat strips above the spectrum and the f_peak - arrow, pitch arrow, and footer below it, dropping lowest-priority + arrow and footer below it, dropping lowest-priority rows first on short terminals. The spectrum fills the rest. """ state = self._state.visualizer_state @@ -875,23 +875,16 @@ def _build_visualizer_rows(self, height: int) -> list[Text]: # Same background the spectrum paints, so the strips/cursor/footer match. row_bg = f"on {bg_color}" if bg_color else "" - # Frequency-domain cursors: arrows onto the spectrum's freq axis plus a - # footer naming each value. pitch uses the on-color, f_peak white/black — - # both guaranteed against the background, and distinct from each other. - f_peak_marker, pitch_marker, footer_parts, f_peak_col = self._build_freq_cursors( - bar_width, on_color, text_color - ) + # Frequency-domain cursor: an arrow onto the spectrum's freq axis plus a + # footer naming the value, in white/black (guaranteed vs the background). + f_peak_marker, footer_parts, f_peak_col = self._build_freq_cursors(bar_width, text_color) vtypes = self._state.visualizer_types clock = self._state.server_now_us # Row budget, highest keep-priority first: peaks, beats (above the - # spectrum), then the f_peak arrow, pitch arrow, and footer (below it). - # Strips and f_peak need their type negotiated. A negotiated pitch keeps - # its row reserved so the arrow appearing/vanishing with confidence - # doesn't shift the layout; an unnegotiated pitch still shows on data. - # On short terminals the lowest-priority rows drop first, spectrum >=1. - has_pitch = self._state.visualizer_state.has_pitch - show_pitch_row = "pitch" in vtypes or has_pitch + # spectrum), then the f_peak arrow and footer (below it). Each row needs + # its type negotiated. On short terminals the lowest-priority rows drop + # first, spectrum >=1. candidates: list[str] = [] if clock is not None and "peak" in vtypes: candidates.append("peak") @@ -899,15 +892,11 @@ def _build_visualizer_rows(self, height: int) -> list[Text]: candidates.append("beat") if "f_peak" in vtypes: candidates.append("f_peak") - if show_pitch_row: - candidates.append("pitch") - if "f_peak" in vtypes or show_pitch_row: candidates.append("footer") visible = candidates[: max(0, height - 1)] show_peaks = "peak" in visible show_beats = "beat" in visible show_f_peak = "f_peak" in visible - show_pitch = "pitch" in visible show_footer = "footer" in visible spectrum_height = max(1, height - len(visible)) @@ -975,9 +964,6 @@ def _build_visualizer_rows(self, height: int) -> list[Text]: if show_f_peak: markers = [f_peak_marker] if f_peak_marker is not None else [] rows.append(self._pad_bg(render_freq_cursor_row(bar_width, markers), bar_width, row_bg)) - if show_pitch: - markers = [pitch_marker] if pitch_marker is not None else [] - rows.append(self._pad_bg(render_freq_cursor_row(bar_width, markers), bar_width, row_bg)) if show_footer: footer = Text() for i, (text, color) in enumerate(footer_parts): @@ -999,26 +985,23 @@ def _pad_bg(row: Text, width: int, row_bg: str) -> Text: def _build_freq_cursors( self, width: int, - pitch_color: str, f_peak_color: str, ) -> tuple[ - tuple[int, str, str] | None, tuple[int, str, str] | None, list[tuple[str, str]], int | None, ]: - """Build frequency-cursor markers and footer labels for tonal readouts. + """Build the frequency-cursor marker and footer label for the f_peak readout. - Returns ``(f_peak_marker, pitch_marker, footer_parts, f_peak_column)`` - where each marker is ``(column, glyph, hex_color)`` or ``None``, - footer_parts are ``(text, hex_color)``, and f_peak_column is the - dominant-frequency spectrum column (or None). The footer text leads with - each cursor's glyph so the readout is self-keying. + Returns ``(f_peak_marker, footer_parts, f_peak_column)`` where the + marker is ``(column, glyph, hex_color)`` or ``None``, footer_parts are + ``(text, hex_color)``, and f_peak_column is the dominant-frequency + spectrum column (or None). The footer text leads with the cursor's glyph + so the readout is self-keying. """ state = self._state.visualizer_state types = self._state.visualizer_types footer: list[tuple[str, str]] = [] - pitch_marker: tuple[int, str, str] | None = None f_peak_marker: tuple[int, str, str] | None = None f_peak_col: int | None = None @@ -1027,19 +1010,10 @@ def _build_freq_cursors( f_peak_col = freq_to_display_column(f_peak_freq, width) if f_peak_col is not None: f_peak_marker = (f_peak_col, "△", f_peak_color) - # Pad to 5 digits (max 20000 Hz) so the pitch label after it stays put. + # Pad to 5 digits (max 20000 Hz) so the label width stays put. footer.append((f"△ f_peak: {f_peak_freq:>5} Hz", f_peak_color)) - note = state.pitch_note - pitch_freq = state.pitch_freq - if state.has_pitch: - assert pitch_freq is not None - col = freq_to_display_column(pitch_freq, width) - if col is not None: - pitch_marker = (col, "▲", pitch_color) - footer.append((f"▲ pitch: {note}", pitch_color)) - - return f_peak_marker, pitch_marker, footer, f_peak_col + return f_peak_marker, footer, f_peak_col @staticmethod def _gutter_label(label: str, gutter: int, strip: Text, row_bg: str) -> Text: @@ -1366,12 +1340,11 @@ def set_visualizer_frame( self, spectrum: list[int] | None, loudness: int | None, - pitch_midi_q88: int | None = None, f_peak_freq: int | None = None, ) -> None: """Update visualizer state with new frame data.""" if self._state.visualizer_enabled: - self._state.visualizer_state.update(spectrum, loudness, pitch_midi_q88, f_peak_freq) + self._state.visualizer_state.update(spectrum, loudness, f_peak_freq) self.refresh() def set_visualizer_types(self, types: frozenset[str]) -> None: diff --git a/sendspin/tui/visualizer.py b/sendspin/tui/visualizer.py index 2199b47..24158e5 100644 --- a/sendspin/tui/visualizer.py +++ b/sendspin/tui/visualizer.py @@ -19,8 +19,6 @@ SPECTRUM_F_MIN = 20 SPECTRUM_F_MAX = 20000 -_NOTE_NAMES = ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B") - @dataclass(frozen=True, slots=True) class PeakEvent: @@ -108,13 +106,6 @@ def loudness_to_colors( return tip, base -def midi_to_note_name(midi_q88: int) -> str: - """Convert an 8.8 fixed-point MIDI value to a note name (e.g. 69.0 -> 'A4').""" - midi = int(round(midi_q88 / 256.0)) - octave = midi // 12 - 1 - return f"{_NOTE_NAMES[midi % 12]}{octave}" - - def _mel(freq_hz: float) -> float: """HTK mel scale, matching the server's spectrum binning.""" return 2595.0 * math.log10(1.0 + freq_hz / 700.0) @@ -147,21 +138,19 @@ def __init__(self) -> None: self._peaks: list[float] = [] self._peak_hold_timers: list[float] = [] self._last_step_monotonic = time.monotonic() - # Latest tonal readouts (held, not smoothed): pitch and dominant freq. - self._pitch_midi_q88: int | None = None + # Latest dominant-frequency readout (held, not smoothed). self._f_peak_freq: int | None = None def update( self, spectrum: list[int] | None, loudness: int | None, - pitch_midi_q88: int | None = None, f_peak_freq: int | None = None, ) -> None: """Update with new frame data. Periodic values are uint16 (0-65535). - Tonal readouts (pitch, f_peak) ride along on the spectrum frame as the - latest received values; they are held as-is for cursor placement. + The f_peak readout rides along on the spectrum frame as the latest + received value; it is held as-is for cursor placement. """ if spectrum is None and loudness is None: self.clear() @@ -171,7 +160,6 @@ def update( self._spectrum_target = [v / 65535.0 for v in spectrum] if loudness is not None: self._loudness_target = loudness / 65535.0 - self._pitch_midi_q88 = pitch_midi_q88 self._f_peak_freq = f_peak_freq def clear(self) -> None: @@ -183,7 +171,6 @@ def clear(self) -> None: self._peaks = [] self._peak_hold_timers = [] self._last_step_monotonic = time.monotonic() - self._pitch_midi_q88 = None self._f_peak_freq = None def _step(self) -> None: @@ -268,21 +255,6 @@ def get_peaks(self) -> list[float]: """Return the current peak hold heights (0.0-1.0 per bin).""" return list(self._peaks) - @property - def pitch_note(self) -> str | None: - """Latest perceived pitch as a note name, or None when not detected.""" - if self._pitch_midi_q88 is None or self._pitch_midi_q88 <= 0: - return None - return midi_to_note_name(self._pitch_midi_q88) - - @property - def pitch_freq(self) -> float | None: - """Latest perceived pitch as a frequency in Hz, or None when not detected.""" - if self._pitch_midi_q88 is None or self._pitch_midi_q88 <= 0: - return None - midi = self._pitch_midi_q88 / 256.0 - return float(440.0 * (2.0 ** ((midi - 69.0) / 12.0))) - @property def f_peak_freq(self) -> int | None: """Latest dominant frequency in Hz, or None when not detected.""" @@ -290,11 +262,6 @@ def f_peak_freq(self) -> int | None: return None return self._f_peak_freq - @property - def has_pitch(self) -> bool: - """Whether a pitch readout is available to display.""" - return self.pitch_note is not None and self.pitch_freq is not None - class BeatState: """Stores beat events and produces a decaying pulse intensity. diff --git a/sendspin/visualizer_connector.py b/sendspin/visualizer_connector.py index 554819c..00113cf 100644 --- a/sendspin/visualizer_connector.py +++ b/sendspin/visualizer_connector.py @@ -41,7 +41,6 @@ def __init__( # Attached to the next emitted spectrum frame so they ride the same # playhead schedule instead of being dropped. self._latest_loudness: int | None = None - self._latest_pitch_midi: int | None = None self._latest_f_peak_freq: int | None = None def attach_client(self, client: SendspinClient) -> None: @@ -60,7 +59,6 @@ def reset(self) -> None: self._timer = None self._pending.clear() self._latest_loudness = None - self._latest_pitch_midi = None self._latest_f_peak_freq = None self._on_frame(VisualizerFrame(timestamp_us=0)) @@ -89,8 +87,6 @@ def _on_visualizer_data(self, frames: list[VisualizerFrame]) -> None: for frame in frames: if frame.loudness is not None: self._latest_loudness = frame.loudness - if frame.pitch_midi_q88 is not None: - self._latest_pitch_midi = frame.pitch_midi_q88 if frame.f_peak_freq is not None: self._latest_f_peak_freq = frame.f_peak_freq if frame.spectrum is None: @@ -106,7 +102,7 @@ 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 - # reset() also drops the last-seen tonal values so a stale readout from + # reset() also drops the last-seen loudness and f_peak so a stale readout from # the previous stream can't survive into the next one. self.reset() @@ -145,7 +141,6 @@ def _emit_due_frames(self) -> None: if latest_due is not None: if latest_due.loudness is None: latest_due.loudness = self._latest_loudness - latest_due.pitch_midi_q88 = self._latest_pitch_midi latest_due.f_peak_freq = self._latest_f_peak_freq self._on_frame(latest_due) diff --git a/tests/test_visualizer_connector.py b/tests/test_visualizer_connector.py index b89c84a..2a3c630 100644 --- a/tests/test_visualizer_connector.py +++ b/tests/test_visualizer_connector.py @@ -62,8 +62,8 @@ async def exercise() -> None: asyncio.run(exercise()) -def test_stream_clear_drops_stale_pitch() -> None: - """stream/clear resets the last-seen pitch so it can't ride into the next stream.""" +def test_stream_clear_drops_stale_f_peak() -> None: + """stream/clear resets the last-seen f_peak so it can't ride into the next stream.""" async def exercise() -> None: received: list[VisualizerFrame] = [] @@ -73,13 +73,13 @@ async def exercise() -> None: on_data = client.visualizer_listeners[0] on_clear = client.stream_clear_listeners[0] - on_data([VisualizerFrame(timestamp_us=1, pitch_midi_q88=17664)]) + on_data([VisualizerFrame(timestamp_us=1, f_peak_freq=440)]) on_clear(None) on_data([VisualizerFrame(timestamp_us=2, spectrum=[1, 2, 3])]) await asyncio.sleep(0.02) # let the scheduled emission fire emitted = received[-1] assert emitted.spectrum == [1, 2, 3] - assert emitted.pitch_midi_q88 is None + assert emitted.f_peak_freq is None asyncio.run(exercise()) diff --git a/tests/tui/test_visualizer.py b/tests/tui/test_visualizer.py index 71f7b83..93603b9 100644 --- a/tests/tui/test_visualizer.py +++ b/tests/tui/test_visualizer.py @@ -15,20 +15,16 @@ VisualizerState, freq_to_display_column, loudness_to_colors, - midi_to_note_name, render_beat_strip, render_peak_strip, render_spectrum, ) -# MIDI 69 (A4) in q8.8 fixed point, well above the pitch-detection floor. -_A4_MIDI_Q88 = 69 * 256 - -def _ui_with_tonal_frame(types: frozenset[str]) -> SendspinUI: - """A visualizer UI holding both a pitch and an f_peak readout.""" +def _ui_with_f_peak_frame(types: frozenset[str]) -> SendspinUI: + """A visualizer UI holding an f_peak readout.""" ui = SendspinUI(0.0, visualizer_enabled=True) - ui.set_visualizer_frame([30000] * 32, 40000, _A4_MIDI_Q88, 1000) + ui.set_visualizer_frame([30000] * 32, 40000, 1000) ui.set_visualizer_types(types) return ui @@ -422,16 +418,7 @@ def test_render_peak_strip_strength_scales_glyph_height() -> None: assert ramp.index(strong) > ramp.index(weak) -# --- pitch / frequency helper tests --- - - -def test_midi_to_note_name_a4() -> None: - assert midi_to_note_name(69 * 256) == "A4" - - -def test_midi_to_note_name_rounds_to_nearest_semitone() -> None: - # 69.6 in 8.8 fixed-point rounds up to MIDI 70 -> A#4. - assert midi_to_note_name(int(69.6 * 256)) == "A#4" +# --- frequency helper tests --- def test_freq_to_display_column_clamps_to_endpoints() -> None: @@ -501,49 +488,23 @@ def test_render_peak_strip_uses_palette_color() -> None: assert styles == {"#abcdef"} -# --- tonal cursor row tests --- +# --- f_peak cursor row tests --- def test_f_peak_arrow_hidden_when_not_negotiated() -> None: """f_peak is type-gated: its data without the negotiated type renders nothing.""" - rows = _ui_with_tonal_frame(frozenset({"pitch"}))._build_visualizer_rows(10) + rows = _ui_with_f_peak_frame(frozenset({"spectrum"}))._build_visualizer_rows(10) plain = "".join(row.plain for row in rows) assert "△" not in plain + assert "f_peak:" not in plain -def test_pitch_arrow_shown_when_not_negotiated() -> None: - """Pitch is data-gated, not type-gated: a confident readout always renders.""" - rows = _ui_with_tonal_frame(frozenset({"f_peak"}))._build_visualizer_rows(10) - plain = "".join(row.plain for row in rows) - assert "▲" in plain - - -def test_negotiated_pitch_row_keeps_layout_stable() -> None: - """A negotiated pitch reserves its row, so a lost readout doesn't shift rows.""" - types = frozenset({"f_peak", "pitch"}) - - with_pitch = SendspinUI(0.0, visualizer_enabled=True) - with_pitch.set_visualizer_frame([30000] * 32, 40000, _A4_MIDI_Q88, 1000) - with_pitch.set_visualizer_types(types) - rows_with_pitch = with_pitch._build_visualizer_rows(10) - - no_pitch = SendspinUI(0.0, visualizer_enabled=True) - no_pitch.set_visualizer_frame([30000] * 32, 40000, 0, 1000) # no pitch readout - no_pitch.set_visualizer_types(types) - rows_no_pitch = no_pitch._build_visualizer_rows(10) - - f_peak_confident = next(i for i, r in enumerate(rows_with_pitch) if "△" in r.plain) - f_peak_silent = next(i for i, r in enumerate(rows_no_pitch) if "△" in r.plain) - assert f_peak_confident == f_peak_silent - - -def test_pitch_arrow_on_separate_line_below_f_peak() -> None: - """With both negotiated, the f_peak arrow gets its own line above pitch's.""" - rows = _ui_with_tonal_frame(frozenset({"f_peak", "pitch"}))._build_visualizer_rows(10) - f_peak_row = next(i for i, row in enumerate(rows) if "△" in row.plain) - pitch_row = next(i for i, row in enumerate(rows) if "▲" in row.plain) - assert f_peak_row < pitch_row - assert "▲" not in rows[f_peak_row].plain +def test_f_peak_arrow_and_footer_shown_when_negotiated() -> None: + rows = _ui_with_f_peak_frame(frozenset({"f_peak"}))._build_visualizer_rows(10) + arrow_row = next(i for i, row in enumerate(rows) if "△" in row.plain) + footer_row = next(i for i, row in enumerate(rows) if "f_peak:" in row.plain) + assert arrow_row < footer_row + assert "1000 Hz" in rows[footer_row].plain # --- VisualizerState activity tests --- From e72b30f98163a8d6346f6cb78c8f64e440cc43b1 Mon Sep 17 00:00:00 2001 From: Willem Vooijs Date: Sun, 25 Oct 2026 19:41:53 -0400 Subject: [PATCH 3/4] Request the TUI visualizer stream in client/state aiosendspin 10 moved the visualizer stream configuration (types, rate_max, spectrum) from the hello support object to client/state, and rejects a support object that still carries it. Enabling the visualizer with `v` therefore raised in SendspinClient(), leaving the TUI detached from the server and the keyboard loop dead. Starting with the visualizer enabled in the settings failed the same way. Co-Authored-By: Claude Opus 5.5 --- sendspin/tui/app.py | 12 +++++-- tests/tui/test_create_client.py | 57 +++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 tests/tui/test_create_client.py diff --git a/sendspin/tui/app.py b/sendspin/tui/app.py index fe9e3ef..568716f 100644 --- a/sendspin/tui/app.py +++ b/sendspin/tui/app.py @@ -48,6 +48,7 @@ ClientHelloVisualizerSupport, StreamStartVisualizer, VisualizerFrame, + VisualizerStatePayload, ) from aiosendspin_mpris import MPRIS_AVAILABLE, SendspinMpris from PIL.Image import Image as PILImage @@ -293,8 +294,12 @@ def _build_artwork_channels() -> list[ArtworkChannel]: @staticmethod def _build_visualizer_support() -> ClientHelloVisualizerSupport: """Build visualizer support payload for client/hello (visualizer@v1).""" - return ClientHelloVisualizerSupport( - buffer_capacity=65536, + return ClientHelloVisualizerSupport(buffer_capacity=65536) + + @staticmethod + def _build_visualizer_state() -> VisualizerStatePayload: + """Build the visualizer stream configuration requested in client/state.""" + return VisualizerStatePayload( types=["loudness", "spectrum", "beat", "peak", "f_peak"], rate_max=30, spectrum=ClientHelloVisualizerSpectrum( @@ -310,8 +315,10 @@ def _create_client(self) -> SendspinClient: args = self._args roles = [Roles.CONTROLLER, Roles.PLAYER, Roles.METADATA, Roles.COLOR] visualizer_support = None + visualizer_state = None if self._visualizer_enabled: visualizer_support = self._build_visualizer_support() + visualizer_state = self._build_visualizer_state() roles.append(Roles.VISUALIZER) artwork_channels: list[ArtworkChannel] | None = None @@ -336,6 +343,7 @@ def _create_client(self) -> SendspinClient: buffer_capacity=32_000_000, ), visualizer_support=visualizer_support, + visualizer_state=visualizer_state, artwork_channels=artwork_channels, output_delay_ms=self._applied_delay_ms, state_supported_commands=[ diff --git a/tests/tui/test_create_client.py b/tests/tui/test_create_client.py new file mode 100644 index 0000000..7544ecd --- /dev/null +++ b/tests/tui/test_create_client.py @@ -0,0 +1,57 @@ +"""Tests for the roles the TUI client is built with.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from types import SimpleNamespace + +import pytest +from aiosendspin.client import SendspinClient +from aiosendspin.models.player import SupportedAudioFormat +from aiosendspin.models.types import AudioCodec, Roles +from aiosendspin.noise import Identity, InMemoryClientPairingStore + +from sendspin.settings import ClientSettings +from sendspin.tui.app import AppArgs, SendspinApp + + +def _make_app(tmp_path: Path, *, visualizer: bool) -> SendspinApp: + args = AppArgs( + audio_device=SimpleNamespace(index=0, name="Fake Device"), + identity=Identity.generate(), + pairing_store=InMemoryClientPairingStore(), + client_name="Test Client", + settings=ClientSettings(_settings_file=tmp_path / "settings.json"), + use_mpris=False, + ) + app = SendspinApp(args) + app._audio_handler = SimpleNamespace(volume=50, muted=False) + app._supported_formats = [ + SupportedAudioFormat(codec=AudioCodec.PCM, channels=2, sample_rate=48000, bit_depth=16) + ] + app._supports_artwork = False + app._visualizer_enabled = visualizer + return app + + +@pytest.mark.parametrize("visualizer", [False, True]) +def test_create_client_accepts_visualizer_toggle(tmp_path: Path, visualizer: bool) -> None: + """Toggling the visualizer rebuilds the client; aiosendspin must accept both role sets.""" + app = _make_app(tmp_path, visualizer=visualizer) + + async def create() -> SendspinClient: + return app._create_client() + + client = asyncio.run(create()) + + assert (Roles.VISUALIZER in client.roles) is visualizer + if visualizer: + # The stream configuration is requested in client/state, not the hello support object. + assert client.visualizer_support is not None + assert not client.visualizer_support.has_stream_config + assert client.visualizer_state is not None + assert "spectrum" in client.visualizer_state.types + assert client.visualizer_state.spectrum is not None + else: + assert client.visualizer_state is None From 0b87a78fce614704507465804a2cdc8182ad2be5 Mon Sep 17 00:00:00 2001 From: Willem Vooijs Date: Sun, 25 Oct 2026 19:50:13 -0400 Subject: [PATCH 4/4] Toggle the TUI visualizer without reconnecting Sendspin 1.0 makes the visualizer request dynamic: client/state carries the requested types, an empty list requests no data, and the server re-sends stream/start when the request changes mid-stream. The TUI now always declares the visualizer role and `v` only updates the requested types, so toggling no longer drops the connection and stops playback. Co-Authored-By: Claude Opus 5.5 --- sendspin/tui/app.py | 87 ++++++++++++++------------------- tests/tui/test_create_client.py | 67 +++++++++++++++++++++---- 2 files changed, 96 insertions(+), 58 deletions(-) diff --git a/sendspin/tui/app.py b/sendspin/tui/app.py index 568716f..ab8a303 100644 --- a/sendspin/tui/app.py +++ b/sendspin/tui/app.py @@ -296,9 +296,13 @@ def _build_visualizer_support() -> ClientHelloVisualizerSupport: """Build visualizer support payload for client/hello (visualizer@v1).""" return ClientHelloVisualizerSupport(buffer_capacity=65536) - @staticmethod - def _build_visualizer_state() -> VisualizerStatePayload: - """Build the visualizer stream configuration requested in client/state.""" + def _build_visualizer_state(self) -> VisualizerStatePayload: + """Build the visualizer stream configuration requested in client/state. + + A disabled visualizer requests no types, so it can be toggled without reconnecting. + """ + if not self._visualizer_enabled: + return VisualizerStatePayload(types=[], rate_max=30) return VisualizerStatePayload( types=["loudness", "spectrum", "beat", "peak", "f_peak"], rate_max=30, @@ -311,15 +315,9 @@ def _build_visualizer_state() -> VisualizerStatePayload: ) def _create_client(self) -> SendspinClient: - """Create a new SendspinClient with roles based on current visualizer state.""" + """Create a new SendspinClient with the roles this TUI supports.""" args = self._args - roles = [Roles.CONTROLLER, Roles.PLAYER, Roles.METADATA, Roles.COLOR] - visualizer_support = None - visualizer_state = None - if self._visualizer_enabled: - visualizer_support = self._build_visualizer_support() - visualizer_state = self._build_visualizer_state() - roles.append(Roles.VISUALIZER) + roles = [Roles.CONTROLLER, Roles.PLAYER, Roles.METADATA, Roles.COLOR, Roles.VISUALIZER] artwork_channels: list[ArtworkChannel] | None = None if self._supports_artwork: @@ -342,8 +340,8 @@ def _create_client(self) -> SendspinClient: supported_formats=self._supported_formats, buffer_capacity=32_000_000, ), - visualizer_support=visualizer_support, - visualizer_state=visualizer_state, + visualizer_support=self._build_visualizer_support(), + visualizer_state=self._build_visualizer_state(), artwork_channels=artwork_channels, output_delay_ms=self._applied_delay_ms, state_supported_commands=[ @@ -385,26 +383,25 @@ def _attach_client(self) -> None: if self._ui is not None: self._ui.set_visualizer_enabled(self._visualizer_enabled) - if self._visualizer_enabled: - self._visualizer_handler = VisualizerHandler( - on_frame=self._handle_visualizer_frame, - ) - self._visualizer_handler.attach_client(self._client) - self._beat_handler = BeatHandler( - on_beat=self._handle_beat, - on_schedule=self._handle_beat_schedule, - ) - self._beat_handler.attach_client(self._client) - self._peak_handler = PeakHandler( - on_peak=self._handle_peak, - on_schedule=self._handle_peak_schedule, - ) - self._peak_handler.attach_client(self._client) - self._listener_unsubscribes.append( - self._client.add_stream_start_listener(self._handle_stream_start) - ) - if self._ui is not None: - self._ui.set_server_clock(self._server_now_us) + self._visualizer_handler = VisualizerHandler( + on_frame=self._handle_visualizer_frame, + ) + self._visualizer_handler.attach_client(self._client) + self._beat_handler = BeatHandler( + on_beat=self._handle_beat, + on_schedule=self._handle_beat_schedule, + ) + self._beat_handler.attach_client(self._client) + self._peak_handler = PeakHandler( + on_peak=self._handle_peak, + on_schedule=self._handle_peak_schedule, + ) + self._peak_handler.attach_client(self._client) + self._listener_unsubscribes.append( + self._client.add_stream_start_listener(self._handle_stream_start) + ) + if self._ui is not None: + self._ui.set_server_clock(self._server_now_us) if self._supports_artwork: self._artwork_handler = ArtworkHandler( @@ -951,27 +948,19 @@ def _set_applied_delay(self, delay_ms: float) -> None: self._applied_delay_ms = delay_ms async def _toggle_visualizer(self) -> None: - """Toggle the visualizer on/off, reconnecting with updated roles.""" + """Toggle the visualizer on/off by changing the types requested in client/state.""" assert self._ui is not None + assert self._client is not None self._visualizer_enabled = not self._visualizer_enabled self._settings.update(visualizer=self._visualizer_enabled) + if not self._visualizer_enabled: + if self._visualizer_handler: + self._visualizer_handler.reset() + self._clear_visualizer_timelines() 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() + # The server answers with a stream/start carrying the new types while a stream runs. + await self._client.set_visualizer_state(self._build_visualizer_state()) def _server_now_us(self) -> int: """Current time in the server clock the beat and peak strips are drawn against. diff --git a/tests/tui/test_create_client.py b/tests/tui/test_create_client.py index 7544ecd..536fae0 100644 --- a/tests/tui/test_create_client.py +++ b/tests/tui/test_create_client.py @@ -1,4 +1,4 @@ -"""Tests for the roles the TUI client is built with.""" +"""Tests for the TUI client's visualizer configuration.""" from __future__ import annotations @@ -10,6 +10,7 @@ from aiosendspin.client import SendspinClient from aiosendspin.models.player import SupportedAudioFormat from aiosendspin.models.types import AudioCodec, Roles +from aiosendspin.models.visualizer import VisualizerStatePayload from aiosendspin.noise import Identity, InMemoryClientPairingStore from sendspin.settings import ClientSettings @@ -36,8 +37,8 @@ def _make_app(tmp_path: Path, *, visualizer: bool) -> SendspinApp: @pytest.mark.parametrize("visualizer", [False, True]) -def test_create_client_accepts_visualizer_toggle(tmp_path: Path, visualizer: bool) -> None: - """Toggling the visualizer rebuilds the client; aiosendspin must accept both role sets.""" +def test_create_client_requests_visualizer_types_in_state(tmp_path: Path, visualizer: bool) -> None: + """The visualizer role is always declared; the setting only picks the requested types.""" app = _make_app(tmp_path, visualizer=visualizer) async def create() -> SendspinClient: @@ -45,13 +46,61 @@ async def create() -> SendspinClient: client = asyncio.run(create()) - assert (Roles.VISUALIZER in client.roles) is visualizer + assert Roles.VISUALIZER in client.roles + # The stream configuration is requested in client/state, not the hello support object. + assert client.visualizer_support is not None + assert not client.visualizer_support.has_stream_config + assert client.visualizer_state is not None if visualizer: - # The stream configuration is requested in client/state, not the hello support object. - assert client.visualizer_support is not None - assert not client.visualizer_support.has_stream_config - assert client.visualizer_state is not None assert "spectrum" in client.visualizer_state.types assert client.visualizer_state.spectrum is not None else: - assert client.visualizer_state is None + assert client.visualizer_state.types == [] + assert client.visualizer_state.spectrum is None + + +class _FakeClient: + def __init__(self) -> None: + self.states: list[VisualizerStatePayload] = [] + self.disconnects = 0 + + async def set_visualizer_state(self, state: VisualizerStatePayload) -> None: + self.states.append(state) + + async def disconnect(self) -> None: + self.disconnects += 1 + + +class _FakeUI: + def __init__(self) -> None: + self.enabled: list[bool] = [] + + def set_visualizer_enabled(self, enabled: bool) -> None: + self.enabled.append(enabled) + + def clear_beats(self) -> None: + pass + + def clear_peaks(self) -> None: + pass + + +def test_toggle_visualizer_updates_state_without_reconnecting(tmp_path: Path) -> None: + """Toggling must keep the connection, so playback continues.""" + app = _make_app(tmp_path, visualizer=False) + client = _FakeClient() + ui = _FakeUI() + app._client = client # type: ignore[assignment] + app._ui = ui # type: ignore[assignment] + + async def toggle_twice() -> None: + await app._toggle_visualizer() + await app._toggle_visualizer() + + asyncio.run(toggle_twice()) + + assert client.disconnects == 0 + assert app._client is client + assert ui.enabled == [True, False] + assert "spectrum" in client.states[0].types + assert client.states[1].types == []