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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,10 @@ Settings are stored in `~/.config/sendspin/`:
| `listen_port` | integer | daemon/serve | Listen port (`--port`, default: 8927) |
| `use_mpris` | boolean | TUI/daemon | Enable MPRIS integration (default: true) |
| `use_hardware_volume` | boolean | TUI/daemon | Control hardware/system output volume instead of software volume (`--hardware-volume true/false`). Default: on for daemon (if available), off for TUI |
| `max_volume` | integer | TUI/daemon | Maximum output volume 1-100 (`--max-volume`, default: 100). Volume commands and reported volume are scaled to it |
| `unpaired_access` | boolean | TUI/daemon | Allow servers to play on this client without pairing (`--unpaired-access true/false`, default: true). See [Pairing](#pairing) |
| `hook_set_volume` | string | TUI/daemon | Script to run for external volume control (`--hook-set-volume`). Receives the effective volume 0-100 as the last argument |
| `hook_volume_state` | object | TUI/daemon | Managed automatically: last output volume (0-100) and mute state sent to the volume hook, stored separately from `player_volume` |
| `hook_start` | string | TUI/daemon | Command to run when audio stream starts |
| `hook_stop` | string | TUI/daemon | Command to run when audio stream stops |
| `manufacturer` | string | TUI/daemon | Manufacturer name reported in the client hello (`--manufacturer`) |
Expand Down Expand Up @@ -298,6 +300,7 @@ Hardware volume is **on by default in daemon mode** and **off by default in TUI
```bash
sendspin --hardware-volume true # Enable for TUI
sendspin daemon --hardware-volume false # Disable for daemon
sendspin --max-volume 60 # Logical 100% maps to 60% output volume
```

If your real volume control lives on another device, you can hand volume changes off to a script instead:
Expand Down
29 changes: 24 additions & 5 deletions sendspin/audio_connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,7 @@ def __init__(
on_format_change: Callable[[str | None, int, int, int], None] | None = None,
on_volume_change: Callable[[int, bool], None] | None = None,
volume_controller: VolumeController | None = None,
max_volume: int = 100,
) -> None:
"""Initialize the audio stream handler.

Expand All @@ -361,7 +362,9 @@ def __init__(
on_format_change: Callback for format changes (codec, sample_rate, bit_depth, channels).
on_volume_change: Callback for volume changes.
volume_controller: Optional external volume controller backend.
max_volume: Output volume (1-100) that logical 100% maps to.
"""
self._max_volume = max(1, min(100, max_volume))
self._audio_device = audio_device
self._volume = volume
self._muted = muted
Expand Down Expand Up @@ -391,6 +394,14 @@ def muted(self) -> bool:
"""Current logical muted state (what the server/user sees)."""
return self._muted

def _to_output_volume(self, logical: int) -> int:
"""Scale a logical (server-facing) volume to the output volume."""
return round(logical * self._max_volume / 100)

def _to_logical_volume(self, output: int) -> int:
"""Scale an output volume back to the logical (server-facing) volume."""
return min(100, round(output * 100 / self._max_volume))
Comment thread
Links2004 marked this conversation as resolved.
Resolved

async def read_initial_volume(self) -> None:
"""Read the effective initial volume state.

Expand All @@ -400,12 +411,16 @@ async def read_initial_volume(self) -> None:
if self._volume_controller is None:
return

self._volume, self._muted = await self._volume_controller.get_state()
output_volume, self._muted = await self._volume_controller.get_state()
if output_volume > self._max_volume:
output_volume = self._max_volume
await self._volume_controller.set_state(output_volume, muted=self._muted)
self._volume = self._to_logical_volume(output_volume)

async def start_volume_monitor(self) -> None:
"""Start external volume monitoring if applicable."""
if self._volume_controller is not None:
await self._volume_controller.start_monitoring(self._publish_volume_state)
await self._volume_controller.start_monitoring(self._on_external_volume_change)

@property
def uses_external_volume_controller(self) -> bool:
Expand All @@ -428,16 +443,20 @@ def set_volume(self, volume: int, *, muted: bool) -> None:
return

if self._audio_worker is not None:
self._audio_worker.set_volume(volume, muted=muted)
self._audio_worker.set_volume(self._to_output_volume(volume), muted=muted)

self._publish_volume_state(volume, muted)

async def _set_external_volume(self, volume: int, *, muted: bool) -> None:
"""Apply volume through the external controller and update logical state."""
assert self._volume_controller is not None
await self._volume_controller.set_state(volume, muted=muted)
await self._volume_controller.set_state(self._to_output_volume(volume), muted=muted)
Comment thread
Links2004 marked this conversation as resolved.
Resolved
self._publish_volume_state(volume, muted)

def _on_external_volume_change(self, output_volume: int, muted: bool) -> None:
"""Handle an externally observed output volume change."""
self._publish_volume_state(self._to_logical_volume(output_volume), muted)

def _publish_volume_state(self, volume: int, muted: bool) -> None:
"""Store and broadcast a new logical volume state if it changed."""
if self._volume == volume and self._muted == muted:
Expand Down Expand Up @@ -486,7 +505,7 @@ def _start_audio_worker(self, client: SendspinClient) -> None:
self._audio_worker = _AudioSyncWorker(
audio_device=self._audio_device,
use_software_volume=not self.uses_external_volume_controller,
volume=self._volume,
volume=self._to_output_volume(self._volume),
muted=self._muted,
)

Expand Down
28 changes: 28 additions & 0 deletions sendspin/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ def add_argument(self, *name_or_flags: str, **kwargs: Any) -> argparse.Action:
"""Add an argument to the target."""


def _max_volume_arg(v: str) -> int:
try:
value = int(v)
except ValueError:
raise argparse.ArgumentTypeError(f"invalid integer: {v!r}") from None
if not 1 <= value <= 100:
raise argparse.ArgumentTypeError("must be between 1 and 100")
return value


def arg_str_to_bool(v: str) -> bool:
s = v.lower()
if s == "true":
Expand Down Expand Up @@ -223,6 +233,13 @@ def _add_player_runtime_options(target: ArgumentTarget, *, suppress_defaults: bo
metavar="{true,false}",
help="Enable or disable hardware/system volume control (daemon: on, TUI: off)",
)
target.add_argument(
"--max-volume",
type=_max_volume_arg,
default=default,
metavar="1-100",
help="Maximum output volume; volume commands and reported volume are scaled to it (default: 100)",
)
target.add_argument(
"--unpaired-access",
default=default,
Expand Down Expand Up @@ -470,6 +487,13 @@ def _build_parser() -> argparse.ArgumentParser:
action="store_true",
help="Disable MPRIS integration",
)
daemon_parser.add_argument(
"--max-volume",
type=_max_volume_arg,
default=None,
metavar="1-100",
help="Maximum output volume; volume commands and reported volume are scaled to it (default: 100)",
)
daemon_parser.add_argument(
"--hardware-volume",
default=None,
Expand Down Expand Up @@ -759,6 +783,7 @@ async def _run_daemon_mode(
use_mpris=args.use_mpris,
preferred_format=_resolve_preferred_format(args.audio_format, audio_device),
volume_controller=volume_controller,
max_volume=args.max_volume,
hook_start=args.hook_start,
hook_stop=args.hook_stop,
manufacturer=args.manufacturer,
Expand Down Expand Up @@ -885,6 +910,8 @@ async def _run_client_mode(args: argparse.Namespace) -> int:
args.hardware_volume = settings.use_hardware_volume
else:
args.hardware_volume = is_daemon and (HW_VOLUME_AVAILABLE or ALSA_AVAILABLE)
if args.max_volume is None:
args.max_volume = settings.max_volume or 100
if args.hook_set_volume is None:
args.hook_set_volume = settings.hook_set_volume
if not args.hook_set_volume and args.hardware_volume and not HW_VOLUME_AVAILABLE:
Expand Down Expand Up @@ -980,6 +1007,7 @@ async def _run_client_mode(args: argparse.Namespace) -> int:
use_mpris=args.use_mpris,
preferred_format=_resolve_preferred_format(args.audio_format, audio_device),
volume_controller=volume_controller,
max_volume=args.max_volume,
hook_start=args.hook_start,
hook_stop=args.hook_stop,
manufacturer=args.manufacturer,
Expand Down
2 changes: 2 additions & 0 deletions sendspin/daemon/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class DaemonArgs:
use_mpris: bool = True
preferred_format: SupportedAudioFormat | None = None
volume_controller: VolumeController | None = None
max_volume: int = 100
hook_start: str | None = None
hook_stop: str | None = None
manufacturer: str | None = None
Expand Down Expand Up @@ -163,6 +164,7 @@ def signal_handler() -> None:
on_format_change=self._handle_format_change,
on_volume_change=self._on_volume_change,
volume_controller=self._args.volume_controller,
max_volume=self._args.max_volume,
)
await self._audio_handler.read_initial_volume()
await self._audio_handler.start_volume_monitor()
Expand Down
14 changes: 10 additions & 4 deletions sendspin/hook_volume.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class HookVolumeController:

The hook script receives the effective output volume as the last argument
in the range 0-100. When muted, the hook receives ``0`` while the logical
volume and mute state remain persisted separately in settings.
volume and mute state are persisted separately in ``hook_volume_state``.
"""

def __init__(self, command: str, settings: ClientSettings) -> None:
Expand All @@ -33,7 +33,7 @@ def __init__(self, command: str, settings: ClientSettings) -> None:
self._settings = settings

async def set_state(self, volume: int, *, muted: bool) -> None:
"""Set external volume and persist the logical state."""
"""Set external volume and persist the output state separately from player_volume."""
if not 0 <= volume <= 100:
raise ValueError(f"Volume must be 0-100, got {volume}")

Expand Down Expand Up @@ -70,10 +70,16 @@ async def set_state(self, volume: int, *, muted: bool) -> None:
stderr.decode().strip() if stderr else "(empty)",
)

self._settings.update(player_volume=volume, player_muted=muted)
self._settings.update(hook_volume_state={"volume": volume, "muted": muted})

async def get_state(self) -> tuple[int, bool]:
"""Return the persisted logical volume state."""
"""Return the persisted hook output state.

Falls back to the player settings for configs that predate ``hook_volume_state``.
"""
state = self._settings.hook_volume_state
if state is not None:
return state["volume"], state["muted"]
volume = max(0, min(100, self._settings.player_volume))
muted = bool(self._settings.player_muted)
return volume, muted
Expand Down
29 changes: 29 additions & 0 deletions sendspin/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,11 @@ class ClientSettings(BaseSettings):
use_mpris: bool = True
audio_format: str | None = None
use_hardware_volume: bool | None = None
max_volume: int | None = None
unpaired_access: bool = True
hook_set_volume: str | None = None
# Output volume/mute last sent to the volume hook: {"volume": int, "muted": bool}.
hook_volume_state: dict[str, Any] | None = None
hook_start: str | None = None
hook_stop: str | None = None
visualizer: bool = False
Expand Down Expand Up @@ -159,7 +162,9 @@ def update(
use_mpris: bool | None = None,
audio_format: str | None = None,
use_hardware_volume: bool | None = None,
max_volume: int | None = None,
hook_set_volume: str | None = None,
hook_volume_state: dict[str, Any] | None = None,
hook_start: str | None = None,
hook_stop: str | None = None,
visualizer: bool | None = None,
Expand Down Expand Up @@ -191,7 +196,9 @@ def update(
"use_mpris": use_mpris,
"audio_format": audio_format,
"use_hardware_volume": use_hardware_volume,
"max_volume": max_volume,
"hook_set_volume": hook_set_volume,
"hook_volume_state": hook_volume_state,
"hook_start": hook_start,
"hook_stop": hook_stop,
"visualizer": visualizer,
Expand Down Expand Up @@ -237,8 +244,30 @@ def _load(self) -> bool:
self.use_mpris = data.get("use_mpris", True)
self.audio_format = data.get("audio_format")
self.use_hardware_volume = data.get("use_hardware_volume")
max_volume = data.get("max_volume")
self.max_volume = (
max_volume
if isinstance(max_volume, int)
and not isinstance(max_volume, bool)
and 1 <= max_volume <= 100
else None
)
self.unpaired_access = data.get("unpaired_access", True)
self.hook_set_volume = data.get("hook_set_volume")
hook_state = data.get("hook_volume_state")
if (
isinstance(hook_state, dict)
and isinstance(hook_state.get("volume"), int)
and not isinstance(hook_state.get("volume"), bool)
and 0 <= hook_state["volume"] <= 100
and isinstance(hook_state.get("muted"), bool)
):
self.hook_volume_state = {
"volume": hook_state["volume"],
"muted": hook_state["muted"],
}
else:
self.hook_volume_state = None
self.hook_start = data.get("hook_start")
self.hook_stop = data.get("hook_stop")
self.visualizer = data.get("visualizer", False)
Expand Down
2 changes: 2 additions & 0 deletions sendspin/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ class AppArgs:
use_mpris: bool = True
preferred_format: SupportedAudioFormat | None = None
volume_controller: VolumeController | None = None
max_volume: int = 100
hook_start: str | None = None
hook_stop: str | None = None
manufacturer: str | None = None
Expand Down Expand Up @@ -485,6 +486,7 @@ def request_shutdown() -> None:
on_format_change=self._handle_format_change,
on_volume_change=self._on_volume_change,
volume_controller=args.volume_controller,
max_volume=args.max_volume,
)
await self._audio_handler.read_initial_volume()

Expand Down
Loading
Loading