Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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`
Expand Down
23 changes: 14 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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`) |
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion sendspin/audio_connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 25 additions & 6 deletions sendspin/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
57 changes: 30 additions & 27 deletions sendspin/daemon/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)."""
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
24 changes: 15 additions & 9 deletions sendspin/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand Down
Loading
Loading