diff --git a/README.md b/README.md index 2b9d004..b6e28f4 100644 --- a/README.md +++ b/README.md @@ -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`) | @@ -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: diff --git a/sendspin/audio_connector.py b/sendspin/audio_connector.py index c7eabac..3f80d9d 100644 --- a/sendspin/audio_connector.py +++ b/sendspin/audio_connector.py @@ -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. @@ -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 @@ -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)) + async def read_initial_volume(self) -> None: """Read the effective initial volume state. @@ -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: @@ -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) 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: @@ -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, ) diff --git a/sendspin/cli.py b/sendspin/cli.py index e5fd56b..429f087 100644 --- a/sendspin/cli.py +++ b/sendspin/cli.py @@ -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": @@ -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, @@ -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, @@ -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, @@ -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: @@ -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, diff --git a/sendspin/daemon/daemon.py b/sendspin/daemon/daemon.py index 2a8c891..3c9c10e 100644 --- a/sendspin/daemon/daemon.py +++ b/sendspin/daemon/daemon.py @@ -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 @@ -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() diff --git a/sendspin/hook_volume.py b/sendspin/hook_volume.py index c042a6e..e54b688 100644 --- a/sendspin/hook_volume.py +++ b/sendspin/hook_volume.py @@ -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: @@ -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}") @@ -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 diff --git a/sendspin/settings.py b/sendspin/settings.py index 78f4c64..e897620 100644 --- a/sendspin/settings.py +++ b/sendspin/settings.py @@ -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 @@ -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, @@ -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, @@ -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) diff --git a/sendspin/tui/app.py b/sendspin/tui/app.py index ab8a303..9c7752c 100644 --- a/sendspin/tui/app.py +++ b/sendspin/tui/app.py @@ -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 @@ -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() diff --git a/tests/test_audio_connector.py b/tests/test_audio_connector.py index e69b66c..bdc620e 100644 --- a/tests/test_audio_connector.py +++ b/tests/test_audio_connector.py @@ -268,6 +268,158 @@ async def exercise() -> None: asyncio.run(exercise()) +class _ScaledController: + def __init__(self, volume: int = 0, muted: bool = False) -> None: + self.state = (volume, muted) + self.calls: list[tuple[int, bool]] = [] + self.callback: object = None + + async def set_state(self, volume: int, *, muted: bool) -> None: + self.calls.append((volume, muted)) + self.state = (volume, muted) + + async def get_state(self) -> tuple[int, bool]: + return self.state + + async def start_monitoring(self, callback: object) -> None: + self.callback = callback + + async def stop_monitoring(self) -> None: + return + + +def _device() -> SimpleNamespace: + return SimpleNamespace(index=0, name="Fake Device") + + +def test_software_volume_is_scaled_by_max_volume(monkeypatch) -> None: + monkeypatch.setattr(audio_connector, "_AudioSyncWorker", _FakeWorker) + _FakeWorker.instances.clear() + + async def exercise() -> None: + changes: list[tuple[int, bool]] = [] + handler = AudioStreamHandler( + audio_device=_device(), + volume=50, + max_volume=60, + on_volume_change=lambda v, m: changes.append((v, m)), + ) + handler.attach_client(_FakeClient()) + worker = _FakeWorker.instances[0] + + # Initial worker volume is scaled; server-visible volume is not. + assert worker.volume == 30 + assert handler.volume == 50 + + handler.set_volume(100, muted=False) + assert worker.volume == 60 + assert handler.volume == 100 + assert changes == [(100, False)] + + handler.set_volume(0, muted=True) + assert worker.volume == 0 + assert handler.volume == 0 + + asyncio.run(exercise()) + + +def test_default_max_volume_does_not_scale(monkeypatch) -> None: + monkeypatch.setattr(audio_connector, "_AudioSyncWorker", _FakeWorker) + _FakeWorker.instances.clear() + + async def exercise() -> None: + handler = AudioStreamHandler(audio_device=_device(), volume=37) + handler.attach_client(_FakeClient()) + worker = _FakeWorker.instances[0] + assert worker.volume == 37 + handler.set_volume(80, muted=False) + assert worker.volume == 80 + + asyncio.run(exercise()) + + +def test_external_controller_set_and_readback_are_scaled() -> None: + async def exercise() -> None: + controller = _ScaledController(volume=30) + changes: list[tuple[int, bool]] = [] + handler = AudioStreamHandler( + audio_device=_device(), + max_volume=60, + on_volume_change=lambda v, m: changes.append((v, m)), + volume_controller=controller, + ) + + # Output 30 at max 60 is logical 50. + await handler.read_initial_volume() + assert handler.volume == 50 + assert controller.calls == [] + + # Logical command is scaled down for the output; logical state stays as sent. + handler.set_volume(100, muted=False) + await asyncio.sleep(0) + assert controller.calls == [(60, False)] + assert handler.volume == 100 + + handler.set_volume(50, muted=True) + await asyncio.sleep(0) + assert controller.calls[-1] == (30, True) + assert handler.volume == 50 + assert handler.muted is True + + # External output change is scaled back up to logical volume. + await handler.start_volume_monitor() + controller.callback(15, False) # type: ignore[operator] + assert handler.volume == 25 + assert handler.muted is False + assert changes[-1] == (25, False) + + asyncio.run(exercise()) + + +def test_max_volume_boundary_values() -> None: + async def exercise() -> None: + # Output above the maximum is clamped on startup and reported as logical 100. + controller = _ScaledController(volume=90) + handler = AudioStreamHandler( + audio_device=_device(), max_volume=60, volume_controller=controller + ) + await handler.read_initial_volume() + assert controller.calls == [(60, False)] + assert handler.volume == 100 + + # Output exactly at the maximum is logical 100; never reported above 100. + await handler.start_volume_monitor() + controller.callback(60, False) # type: ignore[operator] + assert handler.volume == 100 + controller.callback(100, False) # type: ignore[operator] + assert handler.volume == 100 + + # Logical 100 maps to exactly the maximum, 0 maps to 0. + handler.set_volume(100, muted=False) + handler.set_volume(0, muted=False) + await asyncio.sleep(0) + assert controller.calls[-2:] == [(60, False), (0, False)] + + asyncio.run(exercise()) + + +def test_max_volume_is_clamped_to_valid_range(monkeypatch) -> None: + monkeypatch.setattr(audio_connector, "_AudioSyncWorker", _FakeWorker) + _FakeWorker.instances.clear() + + async def exercise() -> None: + over = AudioStreamHandler(audio_device=_device(), volume=100, max_volume=250) + over.attach_client(_FakeClient()) + assert _FakeWorker.instances[-1].volume == 100 + + # Zero would divide by zero on readback; it is raised to 1. + under = AudioStreamHandler(audio_device=_device(), volume=100, max_volume=0) + under.attach_client(_FakeClient()) + assert _FakeWorker.instances[-1].volume == 1 + + asyncio.run(exercise()) + + def test_stream_end_closes_stream_not_just_clears(monkeypatch) -> None: """stream_end must fully close the stream (release the device), not just clear.""" monkeypatch.setattr(audio_connector, "_AudioSyncWorker", _FakeWorker) diff --git a/tests/test_hook_volume.py b/tests/test_hook_volume.py index 0691340..f476c22 100644 --- a/tests/test_hook_volume.py +++ b/tests/test_hook_volume.py @@ -55,7 +55,7 @@ async def exercise() -> None: asyncio.run(exercise()) -def test_set_state_runs_hook_and_persists_logical_volume(monkeypatch, tmp_path) -> None: +def test_set_state_runs_hook_and_persists_hook_state(monkeypatch, tmp_path) -> None: calls: list[tuple[str, ...]] = [] async def fake_exec(*argv: str, stdout: int, stderr: int) -> _FakeProcess: @@ -75,7 +75,12 @@ async def exercise() -> None: assert await controller.get_state() == (42, True) data = json.loads((tmp_path / "settings.json").read_text()) - assert data["player_volume"] == 42 - assert data["player_muted"] is True + assert data["hook_volume_state"] == {"volume": 42, "muted": True} + assert data["player_volume"] == 25 + assert data["player_muted"] is False + + reloaded = ClientSettings(_settings_file=tmp_path / "settings.json") + await reloaded.load() + assert reloaded.hook_volume_state == {"volume": 42, "muted": True} asyncio.run(exercise())