Skip to content
Open
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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ Settings are stored in `~/.config/sendspin/`:
"name": "Living Room",
"audio_device": "2",
"audio_format": "flac:48000:24:2",
"force_audio_format": false,
"log_level": "INFO",
"listen_port": 8927,
"use_mpris": true,
Expand Down Expand Up @@ -151,6 +152,7 @@ Settings are stored in `~/.config/sendspin/`:
| `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`) |
| `audio_format` | string | TUI/daemon | Preferred audio format (`--audio-format`, e.g., `flac:48000:24:2`) |
| `force_audio_format` | boolean | TUI/daemon | Advertise only `audio_format` as supported (`--force-audio-format`, default: false). Requires `audio_format` |
| `log_level` | string | All | Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL |
| `listen_port` | integer | daemon/serve | Listen port (`--port`, default: 8927) |
| `use_mpris` | boolean | TUI/daemon | Enable MPRIS integration (default: true) |
Expand Down Expand Up @@ -289,6 +291,18 @@ The format string uses the pattern `codec:sample_rate:bit_depth:channels`:

The specified format is validated against the audio device on startup. If the device doesn't support it, the player will exit with an error.

#### Forcing the audio format

`--force-audio-format` advertises only the format from `--audio-format` (or the saved `audio_format` setting) instead of just preferring it, so the server cannot pick anything else. It requires an audio format from the CLI or settings. It can also be saved as `"force_audio_format": true`.

```bash
# TUI
sendspin --audio-format pcm:48000:24:2 --force-audio-format

# Daemon
sendspin daemon --audio-format pcm:48000:24:2 --force-audio-format
```

### System Volume Control

On Linux with PulseAudio/PipeWire, Sendspin can control your system output volume directly. Volume adjustments (keyboard shortcuts, server commands) change the system volume. The current system volume is read on startup — the `player_volume` and `player_muted` settings are only used when hardware volume is disabled.
Expand Down
23 changes: 23 additions & 0 deletions sendspin/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,15 @@ def _add_player_runtime_options(target: ArgumentTarget, *, suppress_defaults: bo
"(e.g., flac:48000:24:2). Verified against the audio device on startup."
),
)
target.add_argument(
"--force-audio-format",
action="store_true",
Comment thread
Links2004 marked this conversation as resolved.
Resolved
default=argparse.SUPPRESS if suppress_defaults else False,
help=(
"Advertise only the --audio-format as supported format "
"instead of just preferring it."
),
)
target.add_argument(
"--disable-mpris",
action="store_true",
Expand Down Expand Up @@ -455,6 +464,15 @@ def _build_parser() -> argparse.ArgumentParser:
"(e.g., flac:48000:24:2). Verified against the audio device on startup."
),
)
daemon_parser.add_argument(
"--force-audio-format",
action="store_true",
default=False,
help=(
"Advertise only the --audio-format as supported format "
"instead of just preferring it."
),
)
daemon_parser.add_argument(
"--settings-dir",
type=str,
Expand Down Expand Up @@ -758,6 +776,7 @@ async def _run_daemon_mode(
listen_port=args.listen_port,
use_mpris=args.use_mpris,
preferred_format=_resolve_preferred_format(args.audio_format, audio_device),
force_preferred_format=args.force_audio_format,
volume_controller=volume_controller,
hook_start=args.hook_start,
hook_stop=args.hook_stop,
Expand Down Expand Up @@ -880,6 +899,9 @@ async def _run_client_mode(args: argparse.Namespace) -> int:
args.use_mpris = not args.disable_mpris and settings.use_mpris
if args.audio_format is None:
args.audio_format = settings.audio_format
args.force_audio_format = args.force_audio_format or settings.force_audio_format
if args.force_audio_format and args.audio_format is None:
raise CLIError("--force-audio-format requires --audio-format", 1)
Comment thread
Links2004 marked this conversation as resolved.
Resolved
if args.hardware_volume is None:
if settings.use_hardware_volume is not None:
args.hardware_volume = settings.use_hardware_volume
Expand Down Expand Up @@ -979,6 +1001,7 @@ async def _run_client_mode(args: argparse.Namespace) -> int:
output_delay_ms=args.output_delay_ms,
use_mpris=args.use_mpris,
preferred_format=_resolve_preferred_format(args.audio_format, audio_device),
force_preferred_format=args.force_audio_format,
volume_controller=volume_controller,
hook_start=args.hook_start,
hook_stop=args.hook_stop,
Expand Down
5 changes: 4 additions & 1 deletion sendspin/daemon/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class DaemonArgs:
listen_port: int = 8928
use_mpris: bool = True
preferred_format: SupportedAudioFormat | None = None
force_preferred_format: bool = False
volume_controller: VolumeController | None = None
hook_start: str | None = None
hook_stop: str | None = None
Expand Down Expand Up @@ -85,7 +86,9 @@ def _create_client(self) -> SendspinClient:
client_roles.extend([Roles.METADATA, Roles.CONTROLLER])

supported_formats = detect_supported_audio_formats(self._args.audio_device)
if self._args.preferred_format is not None:
if self._args.preferred_format is not None and self._args.force_preferred_format:
supported_formats = [self._args.preferred_format]
elif self._args.preferred_format is not None:
supported_formats = [f for f in supported_formats if f != self._args.preferred_format]
supported_formats.insert(0, self._args.preferred_format)

Expand Down
4 changes: 4 additions & 0 deletions sendspin/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ class ClientSettings(BaseSettings):
audio_device: str | None = None
use_mpris: bool = True
audio_format: str | None = None
force_audio_format: bool = False
use_hardware_volume: bool | None = None
unpaired_access: bool = True
hook_set_volume: str | None = None
Expand Down Expand Up @@ -158,6 +159,7 @@ def update(
listen_port: int | None = None,
use_mpris: bool | None = None,
audio_format: str | None = None,
force_audio_format: bool | None = None,
use_hardware_volume: bool | None = None,
hook_set_volume: str | None = None,
hook_start: str | None = None,
Expand Down Expand Up @@ -190,6 +192,7 @@ def update(
"listen_port": listen_port,
"use_mpris": use_mpris,
"audio_format": audio_format,
"force_audio_format": force_audio_format,
"use_hardware_volume": use_hardware_volume,
"hook_set_volume": hook_set_volume,
"hook_start": hook_start,
Expand Down Expand Up @@ -236,6 +239,7 @@ def _load(self) -> bool:
self.audio_device = data.get("audio_device")
self.use_mpris = data.get("use_mpris", True)
self.audio_format = data.get("audio_format")
self.force_audio_format = data.get("force_audio_format", False)
self.use_hardware_volume = data.get("use_hardware_volume")
self.unpaired_access = data.get("unpaired_access", True)
self.hook_set_volume = data.get("hook_set_volume")
Expand Down
5 changes: 4 additions & 1 deletion sendspin/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ class AppArgs:
output_delay_ms: float | None = None
use_mpris: bool = True
preferred_format: SupportedAudioFormat | None = None
force_preferred_format: bool = False
volume_controller: VolumeController | None = None
hook_start: str | None = None
hook_stop: str | None = None
Expand Down Expand Up @@ -493,7 +494,9 @@ def request_shutdown() -> None:

# Detect supported audio formats for the output device
supported_formats = detect_supported_audio_formats(args.audio_device)
if args.preferred_format is not None:
if args.preferred_format is not None and args.force_preferred_format:
supported_formats = [args.preferred_format]
elif args.preferred_format is not None:
supported_formats = [f for f in supported_formats if f != args.preferred_format]
supported_formats.insert(0, args.preferred_format)
self._supported_formats = supported_formats
Expand Down
Loading