From b90f5baddf77d3c21e6fa7506023cdd71940a3c6 Mon Sep 17 00:00:00 2001 From: Rudy Date: Mon, 16 Feb 2026 10:43:40 +0100 Subject: [PATCH 1/2] feat(source): add source@v1 cli and daemon reference flow --- README.md | 128 ++++++++++- sendspin/cli.py | 458 +++++++++++++++++++++++++++++++++++++- sendspin/daemon/daemon.py | 172 +++++++++++++- sendspin/settings.py | 68 ++++++ sendspin/source_stream.py | 343 ++++++++++++++++++++++++++++ sendspin/source_utils.py | 118 ++++++++++ 6 files changed, 1276 insertions(+), 11 deletions(-) create mode 100644 sendspin/source_stream.py create mode 100644 sendspin/source_utils.py diff --git a/README.md b/README.md index 75b2c84..fc3b7e3 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,12 @@ Connect to any [Sendspin](https://www.sendspin-audio.com) server and instantly turn your computer into an audio target that can participate in multi-room audio. -Sendspin CLI includes three apps: +Sendspin CLI includes four apps: - **`sendspin`** - Terminal client for interactive use - **`sendspin daemon`** - Background daemon for headless devices - **`sendspin serve`** - Host a Sendspin party to demo Sendspin +- **`sendspin source run`** - Run a source-only source@v1 input client image @@ -32,6 +33,12 @@ uvx sendspin serve /path/to/media.mp3 uvx sendspin serve https://retro.dancewave.online/retrodance.mp3 ``` +Start a source-only input client + +```bash +uvx sendspin source run --url ws://127.0.0.1:8928/sendspin --source-input sine +``` + ## Installation **With uv:** @@ -123,7 +130,24 @@ Settings are stored in `~/.config/sendspin/`: "audio_device": "2", "log_level": "INFO", "listen_port": 8927, - "use_mpris": true + "use_mpris": true, + "source_enabled": false, + "source_input": "linein", + "source_device": null, + "source_codec": "pcm", + "source_sample_rate": 48000, + "source_channels": 2, + "source_bit_depth": 16, + "source_frame_ms": 20, + "source_sine_hz": 440.0, + "source_signal_threshold_db": -45.0, + "source_signal_hold_ms": 300.0, + "source_hook_play": null, + "source_hook_pause": null, + "source_hook_next": null, + "source_hook_previous": null, + "source_hook_activate": null, + "source_hook_deactivate": null } ``` @@ -154,6 +178,23 @@ Settings are stored in `~/.config/sendspin/`: | `use_mpris` | boolean | TUI/daemon | Enable MPRIS integration (default: true) | | `hook_start` | string | TUI/daemon | Command to run when audio stream starts | | `hook_stop` | string | TUI/daemon | Command to run when audio stream stops | +| `source_enabled` | boolean | daemon | Enable source@v1 role on daemon | +| `source_input` | string | daemon | Source input type: `linein` or `sine` | +| `source_device` | string | daemon | Input capture device name/index for `linein` | +| `source_codec` | string | daemon | Advertised source codec (`pcm`, `opus`, `flac`) | +| `source_sample_rate` | integer | daemon | Source sample rate in Hz | +| `source_channels` | integer | daemon | Source channels | +| `source_bit_depth` | integer | daemon | Source bit depth | +| `source_frame_ms` | integer | daemon | Source frame size in ms | +| `source_sine_hz` | float | daemon | Sine frequency when `source_input=sine` | +| `source_signal_threshold_db` | float | daemon | Signal detect threshold in dB | +| `source_signal_hold_ms` | float | daemon | Hold time before signal transition | +| `source_hook_play` | string | daemon | Hook command for source `play` control | +| `source_hook_pause` | string | daemon | Hook command for source `pause` control | +| `source_hook_next` | string | daemon | Hook command for source `next` control | +| `source_hook_previous` | string | daemon | Hook command for source `previous` control | +| `source_hook_activate` | string | daemon | Hook command for source `activate` control | +| `source_hook_deactivate` | string | daemon | Hook command for source `deactivate` control | | `source` | string | serve | Default audio source (file path or URL, ffmpeg input) | | `source_format` | string | serve | ffmpeg container format for audio source | | `clients` | array | serve | Client URLs to connect to (`--client`) | @@ -208,6 +249,20 @@ sendspin --audio-device "MacBook" This is particularly useful when running `sendspin daemon` on headless devices or when you want to route audio to a specific output. +### Audio Input Device Selection + +For source mode and daemon source capture: + +```bash +sendspin --list-input-devices +``` + +Then select a device for source capture: + +```bash +sendspin source run --url ws://127.0.0.1:8928/sendspin --source-input linein --source-device 0 +``` + ### Adjusting Playback Delay The player supports adjusting playback delay to compensate for audio hardware latency or achieve better synchronization across devices. @@ -232,6 +287,67 @@ The daemon runs in the background and logs status messages to stdout. It accepts sendspin daemon --name "Kitchen" --audio-device 2 ``` +Enable source@v1 on daemon: + +```bash +sendspin daemon --source --source-input linein --source-device 0 +``` + +With synthetic input (no capture device required): + +```bash +sendspin daemon --source --source-input sine --source-sine-hz 440 +``` + +Daemon source options: +- `--source` / `--no-source` +- `--source-input {linein,sine}` +- `--source-device ` +- `--source-codec {pcm,opus,flac}` +- `--source-sample-rate ` +- `--source-channels ` +- `--source-bit-depth ` +- `--source-frame-ms ` +- `--source-sine-hz ` +- `--signal-threshold-db ` +- `--signal-hold ` +- `--source-hook-play ` +- `--source-hook-pause ` +- `--source-hook-next ` +- `--source-hook-previous ` +- `--source-hook-activate ` +- `--source-hook-deactivate ` + +### Source-Only Mode + +Run a dedicated source client without player/TUI: + +```bash +sendspin source run --url ws://127.0.0.1:8928/sendspin --source-input sine +``` + +Line-in capture: + +```bash +sendspin source run --url ws://127.0.0.1:8928/sendspin \ + --source-input linein --source-device 0 \ + --source-sample-rate 44100 --source-channels 2 --source-bit-depth 16 \ + --signal-threshold-db -45 --signal-hold 300 +``` + +Optional source control hooks: + +```bash +sendspin source run --url ws://127.0.0.1:8928/sendspin \ + --source-input linein --source-device 0 \ + --source-hook-play "./play.sh" \ + --source-hook-pause "./pause.sh" \ + --source-hook-next "./next.sh" \ + --source-hook-previous "./prev.sh" \ + --source-hook-activate "./power_on.sh" \ + --source-hook-deactivate "./power_off.sh" +``` + ### Hooks You can run external commands when audio streams start or stop. This is useful for controlling amplifiers, lighting, or other home automation: @@ -254,6 +370,14 @@ Hooks receive these environment variables: - `SENDSPIN_CLIENT_ID` - Client identifier - `SENDSPIN_CLIENT_NAME` - Client friendly name +Source control hooks use the same environment variables, with `SENDSPIN_EVENT` set to: +- `source_play` +- `source_pause` +- `source_next` +- `source_previous` +- `source_activate` +- `source_deactivate` + ### Debugging & Troubleshooting If you experience synchronization issues or audio glitches, you can enable detailed logging to help diagnose the problem: diff --git a/sendspin/cli.py b/sendspin/cli.py index 3b1a325..2c1fa43 100644 --- a/sendspin/cli.py +++ b/sendspin/cli.py @@ -10,11 +10,13 @@ import traceback from collections.abc import Sequence from importlib.metadata import version -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, cast from sendspin.settings import ClientSettings, get_client_settings, get_serve_settings +from sendspin.utils import create_task, get_device_info if TYPE_CHECKING: + from aiosendspin.models.source import SourceControl from sendspin.audio import AudioDevice LOGGER = logging.getLogger(__name__) @@ -57,6 +59,103 @@ def list_audio_devices() -> None: sys.exit(1) +def list_input_devices() -> None: + """List all available audio input devices.""" + try: + import sounddevice as sd + except OSError as e: + if "PortAudio library not found" in str(e): + print(PORTAUDIO_NOT_FOUND_MESSAGE) + sys.exit(1) + raise + + try: + devices = sd.query_devices() + default_input = sd.default.device[0] + + print("Available audio input devices:") + print() + listed = 0 + for i, d in enumerate(devices): + max_in = int(d.get("max_input_channels", 0)) + if max_in <= 0: + continue + default_marker = " (default)" if i == default_input else "" + print( + f" [{i}] {d['name']}{default_marker}\n" + f" Channels: {max_in}, Sample rate: {d['default_samplerate']} Hz" + ) + listed += 1 + print("\nTo select an input device:\n sendspin source run --source-device 0") + if listed == 0: + print(" (No input devices found)") + + except Exception as e: # noqa: BLE001 + print(f"Error listing input devices: {e}") + sys.exit(1) + + +def _add_source_control_hook_args(parser: argparse.ArgumentParser) -> None: + """Add optional source control hook arguments to a parser.""" + parser.add_argument( + "--source-hook-play", + type=str, + default=None, + help="Hook to run when source receives play control", + ) + parser.add_argument( + "--source-hook-pause", + type=str, + default=None, + help="Hook to run when source receives pause control", + ) + parser.add_argument( + "--source-hook-next", + type=str, + default=None, + help="Hook to run when source receives next control", + ) + parser.add_argument( + "--source-hook-previous", + type=str, + default=None, + help="Hook to run when source receives previous control", + ) + parser.add_argument( + "--source-hook-activate", + type=str, + default=None, + help="Hook to run when source receives activate control", + ) + parser.add_argument( + "--source-hook-deactivate", + type=str, + default=None, + help="Hook to run when source receives deactivate control", + ) + + +def _resolve_source_control_hooks(args: argparse.Namespace) -> dict[SourceControl, str]: + """Build source control -> hook command map from CLI args.""" + from aiosendspin.models.source import SourceControl + + mapping = { + SourceControl.PLAY: args.source_hook_play, + SourceControl.PAUSE: args.source_hook_pause, + SourceControl.NEXT: args.source_hook_next, + SourceControl.PREVIOUS: args.source_hook_previous, + SourceControl.ACTIVATE: args.source_hook_activate, + SourceControl.DEACTIVATE: args.source_hook_deactivate, + } + return {control: hook for control, hook in mapping.items() if hook} + + +def _resolve_source_controls(args: argparse.Namespace) -> list[SourceControl] | None: + """Build advertised supported source controls from configured hooks.""" + controls = list(_resolve_source_control_hooks(args).keys()) + return controls or None + + def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: """Parse CLI arguments for the Sendspin client.""" parser = argparse.ArgumentParser(description="Sendspin CLI") @@ -189,6 +288,113 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: default=None, help="Command to run when audio stream stops (receives SENDSPIN_* env vars)", ) + daemon_parser.add_argument( + "--source", + action="store_true", + default=None, + help="Enable source@v1 input on this daemon", + ) + daemon_parser.add_argument( + "--no-source", + action="store_false", + dest="source", + default=None, + help="Disable source@v1 input on this daemon", + ) + daemon_parser.add_argument( + "--source-input", + choices=["sine", "linein"], + default=None, + help="Source input type", + ) + daemon_parser.add_argument( + "--source-device", + type=str, + default=None, + help="Input device name or index for line-in capture", + ) + daemon_parser.add_argument( + "--source-codec", + choices=["pcm", "opus", "flac"], + default=None, + help="Audio codec to advertise", + ) + daemon_parser.add_argument( + "--source-sample-rate", + type=int, + default=None, + help="Source sample rate in Hz", + ) + daemon_parser.add_argument( + "--source-channels", + type=int, + default=None, + help="Source channel count", + ) + daemon_parser.add_argument( + "--source-bit-depth", + type=int, + default=None, + help="Source bit depth", + ) + daemon_parser.add_argument( + "--source-frame-ms", + type=int, + default=None, + help="Source frame size in milliseconds", + ) + daemon_parser.add_argument( + "--source-sine-hz", + type=float, + default=None, + help="Sine wave frequency for synthetic source", + ) + daemon_parser.add_argument( + "--signal-threshold-db", + type=float, + default=None, + help="Signal threshold in dB", + ) + daemon_parser.add_argument( + "--signal-hold", + type=float, + default=None, + help="Signal hold in milliseconds", + ) + _add_source_control_hook_args(daemon_parser) + + source_parser = subparsers.add_parser("source", help="Run a source role client") + source_subparsers = source_parser.add_subparsers(dest="source_command") + source_run_parser = source_subparsers.add_parser("run", help="Start a source client") + source_run_parser.add_argument("--url", required=True, help="WebSocket URL of Sendspin server") + source_run_parser.add_argument("--name", default=None, help="Friendly source name") + source_run_parser.add_argument("--id", default=None, help="Source client id") + source_run_parser.add_argument( + "--source-input", + choices=["sine", "linein"], + default="sine", + help="Audio input source (default: sine)", + ) + source_run_parser.add_argument( + "--source-device", + type=str, + default=None, + help="Input device name or index for line-in capture", + ) + source_run_parser.add_argument( + "--source-codec", + choices=["pcm", "opus", "flac"], + default="pcm", + help="Audio codec to advertise (default: pcm)", + ) + source_run_parser.add_argument("--source-sample-rate", type=int, default=48000) + source_run_parser.add_argument("--source-channels", type=int, default=1) + source_run_parser.add_argument("--source-bit-depth", type=int, default=16) + source_run_parser.add_argument("--source-frame-ms", type=int, default=20) + source_run_parser.add_argument("--source-sine-hz", type=float, default=440.0) + source_run_parser.add_argument("--signal-threshold-db", type=float, default=-45.0) + source_run_parser.add_argument("--signal-hold", type=float, default=300.0) + _add_source_control_hook_args(source_run_parser) # Default behavior (client mode) - existing arguments parser.add_argument( @@ -232,6 +438,11 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: action="store_true", help="List available audio output devices and exit", ) + parser.add_argument( + "--list-input-devices", + action="store_true", + help="List available audio input devices and exit", + ) parser.add_argument( "--list-servers", action="store_true", @@ -350,6 +561,28 @@ def _resolve_audio_device(device_arg: str | None) -> AudioDevice: return device +def _resolve_input_defaults(device_arg: str | int | None) -> tuple[int | None, int | None]: + """Resolve sample-rate/channels defaults from selected input device.""" + try: + import sounddevice as sd + except Exception: # noqa: BLE001 + return None, None + + try: + if device_arg is None: + device_index = sd.default.device[0] + elif isinstance(device_arg, str) and device_arg.isdigit(): + device_index = int(device_arg) + else: + device_index = device_arg + info = sd.query_devices(device_index, kind="input") + rate = int(info.get("default_samplerate", 0)) or None + channels = int(info.get("max_input_channels", 0)) or None + return rate, channels + except Exception: # noqa: BLE001 + return None, None + + def _resolve_client_info(client_id: str | None, client_name: str | None) -> tuple[str, str]: """Determine client ID and name, using hostname as fallback.""" if client_id is not None and client_name is not None: @@ -365,6 +598,148 @@ def _resolve_client_info(client_id: str | None, client_name: str | None) -> tupl ) +def _resolve_role_client_info( + client_id: str | None, client_name: str | None, *, prefix: str +) -> tuple[str, str]: + """Resolve client id/name for non-default roles.""" + if client_id is not None and client_name is not None: + return client_id, client_name + hostname = socket.gethostname() or "unknown" + return ( + client_id or f"{prefix}-{hostname}", + client_name or hostname, + ) + + +async def _run_source_mode(args: argparse.Namespace) -> int: + """Run a source-only client.""" + from aiosendspin.client import SendspinClient + from aiosendspin.models.source import ( + ClientHelloSourceSupport, + InputStreamRequestFormatSource, + SourceCommandPayload, + SourceFeatures, + SourceFormat, + SourceStateType, + ) + from aiosendspin.models.types import AudioCodec, Roles + from sendspin.source_stream import SourceStreamConfig, SourceStreamer + + client_id, client_name = _resolve_role_client_info(args.id, args.name, prefix="sendspin-source") + if args.source_device is not None and args.source_input == "sine": + args.source_input = "linein" + + if args.source_device is not None and args.source_input == "linein": + if args.source_sample_rate == 48000 and args.source_channels == 1: + default_rate, default_channels = _resolve_input_defaults(args.source_device) + if default_rate: + args.source_sample_rate = default_rate + if default_channels: + args.source_channels = default_channels + + source_support = ClientHelloSourceSupport( + supported_formats=[ + SourceFormat( + codec=AudioCodec(args.source_codec), + channels=args.source_channels, + sample_rate=args.source_sample_rate, + bit_depth=args.source_bit_depth, + ) + ], + controls=_resolve_source_controls(args), + features=SourceFeatures(level=True, line_sense=True), + ) + + if args.source_codec != "pcm": + raise CLIError("Source demo currently only supports PCM frame generation") + + client_kwargs: dict[str, Any] = { + "client_id": client_id, + "client_name": client_name, + "roles": [Roles("source@v1")], + "device_info": get_device_info(), + "source_support": source_support, + } + client = SendspinClient(**client_kwargs) + client_any = cast("Any", client) + + streaming = asyncio.Event() + connected_event = asyncio.Event() + device = args.source_device + if isinstance(device, str) and device.isdigit(): + device = int(device) + + streamer = SourceStreamer( + client, + SourceStreamConfig( + codec=AudioCodec(args.source_codec), + input=args.source_input, + device=device, + sample_rate=args.source_sample_rate, + channels=args.source_channels, + bit_depth=args.source_bit_depth, + frame_ms=args.source_frame_ms, + signal_threshold_db=args.signal_threshold_db, + signal_hold_ms=args.signal_hold, + sine_hz=args.source_sine_hz, + control_hooks=_resolve_source_control_hooks(args), + hook_client_id=client_id, + hook_client_name=client_name, + hook_server_url=args.url, + ), + logger=LOGGER, + ) + + def _on_source_command(payload: SourceCommandPayload) -> None: + create_task(streamer.handle_source_command(payload, streaming)) + + def _on_format_request(payload: InputStreamRequestFormatSource) -> None: + create_task(streamer.handle_format_request(payload)) + + client_any.add_source_command_listener(_on_source_command) + client_any.add_input_stream_request_format_listener(_on_format_request) + + def _on_disconnect() -> None: + streaming.clear() + connected_event.clear() + + client.add_disconnect_listener(_on_disconnect) + + async def _connect_loop() -> None: + backoff = 1.0 + while True: + if not client.connected: + try: + LOGGER.info("Connecting to Sendspin server at %s", args.url) + await client.connect(args.url) + connected_event.set() + backoff = 1.0 + initial_state = ( + SourceStateType.STREAMING if streaming.is_set() else SourceStateType.IDLE + ) + if streaming.is_set(): + await streamer.send_input_stream_start() + await streamer.send_state(initial_state) + except Exception as err: # noqa: BLE001 + LOGGER.warning("Source connection failed: %s", err) + await asyncio.sleep(backoff) + backoff = min(backoff * 2.0, 30.0) + continue + await asyncio.sleep(1.0) + + connect_task = create_task(_connect_loop()) + stream_task = create_task(streamer.run(streaming)) + try: + await asyncio.gather(connect_task, stream_task) + except asyncio.CancelledError: + return 0 + finally: + connect_task.cancel() + stream_task.cancel() + await client.disconnect() + return 0 + + async def _run_serve_mode(args: argparse.Namespace) -> int: """Run the server mode.""" from sendspin.serve import ServeConfig, run_server @@ -411,6 +786,15 @@ async def _run_daemon_mode(args: argparse.Namespace, settings: ClientSettings) - from sendspin.daemon.daemon import DaemonArgs, SendspinDaemon client_id, client_name = _resolve_client_info(args.id, args.name) + if args.source_device is not None and args.source_input == "sine": + args.source_input = "linein" + if args.source_device is not None and args.source_input == "linein": + if args.source_sample_rate == 48000 and args.source_channels == 2: + default_rate, default_channels = _resolve_input_defaults(args.source_device) + if default_rate: + args.source_sample_rate = default_rate + if default_channels: + args.source_channels = default_channels daemon_args = DaemonArgs( audio_device=_resolve_audio_device(args.audio_device), @@ -423,6 +807,23 @@ async def _run_daemon_mode(args: argparse.Namespace, settings: ClientSettings) - use_mpris=args.use_mpris, hook_start=args.hook_start, hook_stop=args.hook_stop, + source_enabled=args.source, + source_input=args.source_input, + source_device=args.source_device, + source_codec=args.source_codec, + source_sample_rate=args.source_sample_rate, + source_channels=args.source_channels, + source_bit_depth=args.source_bit_depth, + source_frame_ms=args.source_frame_ms, + source_sine_hz=args.source_sine_hz, + source_signal_threshold_db=args.signal_threshold_db, + source_signal_hold_ms=args.signal_hold, + source_hook_play=args.source_hook_play, + source_hook_pause=args.source_hook_pause, + source_hook_next=args.source_hook_next, + source_hook_previous=args.source_hook_previous, + source_hook_activate=args.source_hook_activate, + source_hook_deactivate=args.source_hook_deactivate, ) daemon = SendspinDaemon(daemon_args) @@ -444,10 +845,29 @@ def main() -> int: traceback.print_exc() return 1 + if args.command == "source": + if args.source_command != "run": + print("Error: source requires a subcommand (use: source run)") + return 1 + try: + return asyncio.run(_run_source_mode(args)) + except KeyboardInterrupt: + return 0 + except CLIError as e: + print(f"Error: {e}") + return e.exit_code + except Exception as e: + print(f"Source error: {e}") + traceback.print_exc() + return 1 + # Handle --list-audio-devices before starting async runtime if args.list_audio_devices: list_audio_devices() return 0 + if args.list_input_devices: + list_input_devices() + return 0 if args.list_servers: asyncio.run(list_servers()) @@ -507,6 +927,42 @@ async def _run_client_mode(args: argparse.Namespace) -> int: if args.hook_stop is None: args.hook_stop = settings.hook_stop + if is_daemon: + if args.source is None: + args.source = settings.source_enabled + if args.source_input is None: + args.source_input = settings.source_input + if args.source_device is None: + args.source_device = settings.source_device + if args.source_codec is None: + args.source_codec = settings.source_codec + if args.source_sample_rate is None: + args.source_sample_rate = settings.source_sample_rate + if args.source_channels is None: + args.source_channels = settings.source_channels + if args.source_bit_depth is None: + args.source_bit_depth = settings.source_bit_depth + if args.source_frame_ms is None: + args.source_frame_ms = settings.source_frame_ms + if args.source_sine_hz is None: + args.source_sine_hz = settings.source_sine_hz + if args.signal_threshold_db is None: + args.signal_threshold_db = settings.source_signal_threshold_db + if args.signal_hold is None: + args.signal_hold = settings.source_signal_hold_ms + if args.source_hook_play is None: + args.source_hook_play = settings.source_hook_play + if args.source_hook_pause is None: + args.source_hook_pause = settings.source_hook_pause + if args.source_hook_next is None: + args.source_hook_next = settings.source_hook_next + if args.source_hook_previous is None: + args.source_hook_previous = settings.source_hook_previous + if args.source_hook_activate is None: + args.source_hook_activate = settings.source_hook_activate + if args.source_hook_deactivate is None: + args.source_hook_deactivate = settings.source_hook_deactivate + # Set up logging with resolved log level logging.basicConfig(level=getattr(logging, args.log_level)) diff --git a/sendspin/daemon/daemon.py b/sendspin/daemon/daemon.py index 4eedfb1..7752341 100644 --- a/sendspin/daemon/daemon.py +++ b/sendspin/daemon/daemon.py @@ -6,7 +6,9 @@ import contextlib import logging import signal +from collections.abc import Callable from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, cast from aiohttp import ClientError, web from aiosendspin.client import ClientListener, SendspinClient @@ -16,8 +18,10 @@ ServerCommandPayload, ) from aiosendspin.models.player import ClientHelloPlayerSupport +from aiosendspin.models.source import SourceControl from aiosendspin_mpris import MPRIS_AVAILABLE, SendspinMpris from aiosendspin.models.types import ( + AudioCodec, GoodbyeReason, PlayerCommand, PlayerStateType, @@ -30,6 +34,9 @@ from sendspin.settings import ClientSettings from sendspin.utils import create_task, get_device_info +if TYPE_CHECKING: + from sendspin.source_stream import SourceStreamer + logger = logging.getLogger(__name__) @@ -47,6 +54,23 @@ class DaemonArgs: use_mpris: bool = True hook_start: str | None = None hook_stop: str | None = None + source_enabled: bool = False + source_input: str = "linein" + source_device: str | None = None + source_codec: str = "pcm" + source_sample_rate: int = 48000 + source_channels: int = 2 + source_bit_depth: int = 16 + source_frame_ms: int = 20 + source_sine_hz: float = 440.0 + source_signal_threshold_db: float = -45.0 + source_signal_hold_ms: float = 300.0 + source_hook_play: str | None = None + source_hook_pause: str | None = None + source_hook_next: str | None = None + source_hook_previous: str | None = None + source_hook_activate: str | None = None + source_hook_deactivate: str | None = None class SendspinDaemon: @@ -68,27 +92,57 @@ def __init__(self, args: DaemonArgs) -> None: self._static_delay_ms: float = 0.0 self._connection_lock: asyncio.Lock | None = None self._server_url: str | None = None + self._source_task: asyncio.Task[None] | None = None + self._source_streaming: asyncio.Event | None = None + self._source_unsubscribe: Callable[[], None] | None = None + self._source_format_unsubscribe: Callable[[], None] | None = None + self._source_streamer: SourceStreamer | None = None def _create_client(self, static_delay_ms: float = 0.0) -> SendspinClient: """Create a new SendspinClient instance.""" client_roles = [Roles.PLAYER] if MPRIS_AVAILABLE and self._args.use_mpris: client_roles.extend([Roles.METADATA, Roles.CONTROLLER]) + source_support = None + if self._args.source_enabled: + from aiosendspin.models.source import ( + ClientHelloSourceSupport, + SourceFeatures, + SourceFormat, + ) + + client_roles.append(Roles("source@v1")) + controls = list(self._source_control_hooks().keys()) or None + source_support = ClientHelloSourceSupport( + supported_formats=[ + SourceFormat( + codec=AudioCodec(self._args.source_codec), + channels=self._args.source_channels, + sample_rate=self._args.source_sample_rate, + bit_depth=self._args.source_bit_depth, + ) + ], + controls=controls, + features=SourceFeatures(level=True, line_sense=True), + ) supported_formats = detect_supported_audio_formats(self._args.audio_device.index) - return SendspinClient( - client_id=self._args.client_id, - client_name=self._args.client_name, - roles=client_roles, - device_info=get_device_info(), - player_support=ClientHelloPlayerSupport( + client_kwargs: dict[str, Any] = { + "client_id": self._args.client_id, + "client_name": self._args.client_name, + "roles": client_roles, + "device_info": get_device_info(), + "player_support": ClientHelloPlayerSupport( supported_formats=supported_formats, buffer_capacity=32_000_000, supported_commands=[PlayerCommand.VOLUME, PlayerCommand.MUTE], ), - static_delay_ms=static_delay_ms, - ) + "static_delay_ms": static_delay_ms, + } + if source_support is not None: + client_kwargs["source_support"] = source_support + return SendspinClient(**client_kwargs) async def run(self) -> int: """Run the daemon.""" @@ -135,6 +189,7 @@ def signal_handler() -> None: except asyncio.CancelledError: logger.debug("Daemon cancelled") finally: + await self._stop_source_streaming() await self._stop_mpris_and_audio() if self._client is not None: await self._client.disconnect() @@ -159,6 +214,8 @@ async def _run_client_initiated(self, static_delay_ms: float) -> None: self._audio_handler.attach_client(self._client) self._server_url = self._args.url self._client.add_server_command_listener(self._handle_server_command) + self._attach_source_command_listener() + await self._start_source_streaming() await self._connection_loop(self._args.url) async def _run_server_initiated(self, static_delay_ms: float) -> None: @@ -219,6 +276,8 @@ async def _handle_server_connection(self, ws: web.WebSocketResponse) -> None: self._client = client self._audio_handler.attach_client(client) client.add_server_command_listener(self._handle_server_command) + self._attach_source_command_listener() + await self._start_source_streaming() if MPRIS_AVAILABLE and self._args.use_mpris: self._mpris = SendspinMpris(client) self._mpris.start() @@ -252,6 +311,7 @@ async def _handle_server_connection(self, ws: web.WebSocketResponse) -> None: # Only cleanup if we're still the active client (not replaced by new connection) if self._client is client: await self._stop_mpris_and_audio() + await self._stop_source_streaming() async def _connection_loop(self, url: str) -> None: """Run the connection loop with automatic reconnection (client-initiated mode).""" @@ -291,6 +351,90 @@ async def _connection_loop(self, url: str) -> None: logger.exception("Unexpected error during connection") break + async def _start_source_streaming(self) -> None: + """Start source streaming worker if source mode is enabled.""" + if not self._args.source_enabled or self._client is None: + return + if self._source_task is not None and not self._source_task.done(): + return + from sendspin.source_stream import SourceStreamConfig, SourceStreamer + + device: str | int | None = self._args.source_device + if isinstance(device, str) and device.isdigit(): + device = int(device) + + self._source_streaming = asyncio.Event() + self._source_streamer = SourceStreamer( + self._client, + SourceStreamConfig( + codec=AudioCodec(self._args.source_codec), + input=self._args.source_input, + device=device, + sample_rate=self._args.source_sample_rate, + channels=self._args.source_channels, + bit_depth=self._args.source_bit_depth, + frame_ms=self._args.source_frame_ms, + signal_threshold_db=self._args.source_signal_threshold_db, + signal_hold_ms=self._args.source_signal_hold_ms, + sine_hz=self._args.source_sine_hz, + control_hooks=self._source_control_hooks(), + hook_client_id=self._args.client_id, + hook_client_name=self._args.client_name, + hook_server_url=self._server_url, + ), + logger=logger, + ) + + async def _run_stream() -> None: + assert self._source_streaming is not None + assert self._source_streamer is not None + await self._source_streamer.run(self._source_streaming) + + self._source_task = create_task(_run_stream()) + + async def _stop_source_streaming(self) -> None: + """Stop source streaming worker and listeners.""" + if self._source_task is not None: + if self._source_streaming is not None: + self._source_streaming.clear() + self._source_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._source_task + self._source_task = None + self._source_streaming = None + self._source_streamer = None + if self._source_unsubscribe is not None: + self._source_unsubscribe() + self._source_unsubscribe = None + if self._source_format_unsubscribe is not None: + self._source_format_unsubscribe() + self._source_format_unsubscribe = None + + def _attach_source_command_listener(self) -> None: + """Attach source command listener when source role is enabled.""" + if not self._args.source_enabled or self._client is None: + return + if self._source_unsubscribe is not None: + return + + def _on_source_command(payload: Any) -> None: + streamer = self._source_streamer + if self._source_streaming is None or streamer is None: + return + create_task(streamer.handle_source_command(payload, self._source_streaming)) + + def _on_format_request(payload: Any) -> None: + streamer = self._source_streamer + if streamer is None: + return + create_task(streamer.handle_format_request(payload)) + + client_any = cast("Any", self._client) + self._source_unsubscribe = client_any.add_source_command_listener(_on_source_command) + self._source_format_unsubscribe = client_any.add_input_stream_request_format_listener( + _on_format_request + ) + def _handle_server_command(self, payload: ServerCommandPayload) -> None: """Handle server commands for player volume/mute control and save to settings.""" if payload.player is None or self._settings is None or self._client is None: @@ -350,3 +494,15 @@ def _on_stream_event(self, event: str) -> None: client_name=self._args.client_name, ) ) + + def _source_control_hooks(self) -> dict[SourceControl, str]: + """Return configured source control hook mapping.""" + mapping = { + SourceControl.PLAY: self._args.source_hook_play, + SourceControl.PAUSE: self._args.source_hook_pause, + SourceControl.NEXT: self._args.source_hook_next, + SourceControl.PREVIOUS: self._args.source_hook_previous, + SourceControl.ACTIVATE: self._args.source_hook_activate, + SourceControl.DEACTIVATE: self._args.source_hook_deactivate, + } + return {control: hook for control, hook in mapping.items() if hook} diff --git a/sendspin/settings.py b/sendspin/settings.py index 3bd7643..51895e8 100644 --- a/sendspin/settings.py +++ b/sendspin/settings.py @@ -115,6 +115,23 @@ class ClientSettings(BaseSettings): use_mpris: bool = True hook_start: str | None = None hook_stop: str | None = None + source_enabled: bool = False + source_input: str = "linein" + source_device: str | None = None + source_codec: str = "pcm" + source_sample_rate: int = 48_000 + source_channels: int = 2 + source_bit_depth: int = 16 + source_frame_ms: int = 20 + source_sine_hz: float = 440.0 + source_signal_threshold_db: float = -45.0 + source_signal_hold_ms: float = 300.0 + source_hook_play: str | None = None + source_hook_pause: str | None = None + source_hook_next: str | None = None + source_hook_previous: str | None = None + source_hook_activate: str | None = None + source_hook_deactivate: str | None = None def update( self, @@ -131,6 +148,23 @@ def update( use_mpris: bool | None = None, hook_start: str | None = None, hook_stop: str | None = None, + source_enabled: bool | None = None, + source_input: str | None = None, + source_device: str | None = None, + source_codec: str | None = None, + source_sample_rate: int | None = None, + source_channels: int | None = None, + source_bit_depth: int | None = None, + source_frame_ms: int | None = None, + source_sine_hz: float | None = None, + source_signal_threshold_db: float | None = None, + source_signal_hold_ms: float | None = None, + source_hook_play: str | None = None, + source_hook_pause: str | None = None, + source_hook_next: str | None = None, + source_hook_previous: str | None = None, + source_hook_activate: str | None = None, + source_hook_deactivate: str | None = None, ) -> None: """Update settings fields. Only changed fields trigger a save.""" changed = False @@ -157,6 +191,23 @@ def update( "use_mpris": use_mpris, "hook_start": hook_start, "hook_stop": hook_stop, + "source_enabled": source_enabled, + "source_input": source_input, + "source_device": source_device, + "source_codec": source_codec, + "source_sample_rate": source_sample_rate, + "source_channels": source_channels, + "source_bit_depth": source_bit_depth, + "source_frame_ms": source_frame_ms, + "source_sine_hz": source_sine_hz, + "source_signal_threshold_db": source_signal_threshold_db, + "source_signal_hold_ms": source_signal_hold_ms, + "source_hook_play": source_hook_play, + "source_hook_pause": source_hook_pause, + "source_hook_next": source_hook_next, + "source_hook_previous": source_hook_previous, + "source_hook_activate": source_hook_activate, + "source_hook_deactivate": source_hook_deactivate, } ) or changed @@ -186,6 +237,23 @@ def _load(self) -> None: self.use_mpris = data.get("use_mpris", True) self.hook_start = data.get("hook_start") self.hook_stop = data.get("hook_stop") + self.source_enabled = data.get("source_enabled", False) + self.source_input = data.get("source_input", "linein") + self.source_device = data.get("source_device") + self.source_codec = data.get("source_codec", "pcm") + self.source_sample_rate = data.get("source_sample_rate", 48_000) + self.source_channels = data.get("source_channels", 2) + self.source_bit_depth = data.get("source_bit_depth", 16) + self.source_frame_ms = data.get("source_frame_ms", 20) + self.source_sine_hz = data.get("source_sine_hz", 440.0) + self.source_signal_threshold_db = data.get("source_signal_threshold_db", -45.0) + self.source_signal_hold_ms = data.get("source_signal_hold_ms", 300.0) + self.source_hook_play = data.get("source_hook_play") + self.source_hook_pause = data.get("source_hook_pause") + self.source_hook_next = data.get("source_hook_next") + self.source_hook_previous = data.get("source_hook_previous") + self.source_hook_activate = data.get("source_hook_activate") + self.source_hook_deactivate = data.get("source_hook_deactivate") logger.info( "Loaded settings from %s: volume=%d%%, muted=%s", self._settings_file, diff --git a/sendspin/source_stream.py b/sendspin/source_stream.py new file mode 100644 index 0000000..9c02fc9 --- /dev/null +++ b/sendspin/source_stream.py @@ -0,0 +1,343 @@ +"""Source streaming helpers for CLI and daemon.""" + +from __future__ import annotations + +import asyncio +import logging +import math +import struct +from dataclasses import dataclass +from typing import Any, cast + +from aiosendspin.client import SendspinClient +from aiosendspin.models.source import ( + InputStreamRequestFormatSource, + InputStreamStartSource, + SourceClientCommand, + SourceCommand, + SourceCommandPayload, + SourceControl, + SourceSignalType, + SourceStatePayload, + SourceStateType, +) +from aiosendspin.models.types import AudioCodec + +from sendspin.hooks import run_hook +from sendspin.source_utils import SourceSignalReporter, calc_level +from sendspin.utils import create_task + + +@dataclass(slots=True) +class SourceStreamConfig: + """Configuration for source streaming.""" + + codec: AudioCodec + input: str + device: str | int | None + sample_rate: int + channels: int + bit_depth: int + frame_ms: int + signal_threshold_db: float + signal_hold_ms: float + sine_hz: float + control_hooks: dict[SourceControl, str] | None = None + hook_client_id: str | None = None + hook_client_name: str | None = None + hook_server_url: str | None = None + + +class SourceStreamer: + """Stream source audio with signal detection.""" + + def __init__( + self, + client: SendspinClient, + config: SourceStreamConfig, + *, + logger: logging.Logger, + ) -> None: + self._client = client + self._config = config + self._logger = logger + self._reporter = SourceSignalReporter( + threshold_db=config.signal_threshold_db, + hold_ms=config.signal_hold_ms, + send_state=self._send_state, + send_command=self._send_command, + ) + self._control_hooks = config.control_hooks or {} + + def update_vad( + self, *, threshold_db: float | None = None, hold_ms: float | None = None + ) -> None: + """Apply updated VAD settings.""" + self._reporter.update_vad(threshold_db=threshold_db, hold_ms=hold_ms) + + async def send_input_stream_start(self) -> None: + """Send input_stream/start with the current source format.""" + if not self._client.connected: + return + try: + client_any = cast("Any", self._client) + await client_any.send_input_stream_start( + InputStreamStartSource( + codec=self._config.codec, + channels=self._config.channels, + sample_rate=self._config.sample_rate, + bit_depth=self._config.bit_depth, + codec_header=None, + ) + ) + except RuntimeError: + return + + async def send_input_stream_end(self) -> None: + """Send input_stream/end to stop the input stream.""" + if not self._client.connected: + return + try: + client_any = cast("Any", self._client) + await client_any.send_input_stream_end() + except RuntimeError: + return + + async def handle_format_request(self, request: InputStreamRequestFormatSource) -> None: + """Handle input_stream/request-format from the server.""" + requested = SourceStreamConfig( + codec=request.codec or self._config.codec, + input=self._config.input, + device=self._config.device, + sample_rate=request.sample_rate or self._config.sample_rate, + channels=request.channels or self._config.channels, + bit_depth=request.bit_depth or self._config.bit_depth, + frame_ms=self._config.frame_ms, + signal_threshold_db=self._config.signal_threshold_db, + signal_hold_ms=self._config.signal_hold_ms, + sine_hz=self._config.sine_hz, + ) + if ( + requested.codec != self._config.codec + or requested.sample_rate != self._config.sample_rate + or requested.channels != self._config.channels + or requested.bit_depth != self._config.bit_depth + ): + self._logger.warning("Input stream format change not supported; keeping current format") + await self.send_input_stream_start() + + def reset_signal(self) -> None: + """Reset signal detection state.""" + self._reporter.reset() + + async def send_state( + self, + state: SourceStateType, + *, + level: float | None = None, + signal: SourceSignalType | None = None, + ) -> None: + """Send an explicit source state update.""" + if signal is None: + signal = ( + SourceSignalType.PRESENT + if state == SourceStateType.STREAMING + else SourceSignalType.ABSENT + ) + if level is None: + level = 0.5 if state == SourceStateType.STREAMING else 0.0 + await self._send_state(state, level, signal) + + async def run(self, streaming: asyncio.Event) -> None: + """Run the source streaming loop.""" + if self._config.input == "sine": + await self._stream_sine(streaming) + else: + await self._stream_linein(streaming) + + async def handle_source_command( + self, + payload: SourceCommandPayload, + streaming: asyncio.Event, + ) -> None: + """Apply server source commands to local streaming state.""" + if payload.vad is not None: + self.update_vad( + threshold_db=payload.vad.threshold_db, + hold_ms=payload.vad.hold_ms, + ) + self._logger.info( + "Updated VAD settings: threshold_db=%s hold_ms=%s", + payload.vad.threshold_db, + payload.vad.hold_ms, + ) + + if payload.control is not None: + self._handle_control(payload.control) + + if payload.command == SourceCommand.START: + streaming.set() + self.reset_signal() + await self.send_input_stream_start() + await self.send_state( + SourceStateType.STREAMING, + level=0.0, + signal=SourceSignalType.UNKNOWN, + ) + self._logger.info("Source start command received") + elif payload.command == SourceCommand.STOP: + streaming.clear() + self.reset_signal() + await self.send_input_stream_end() + await self.send_state( + SourceStateType.IDLE, + level=0.0, + signal=SourceSignalType.ABSENT, + ) + self._logger.info("Source stop command received") + + def _handle_control(self, control: SourceControl) -> None: + """Run hook for an incoming source control command.""" + hook = self._control_hooks.get(control) + if hook is None: + self._logger.debug("Source control received: %s (no hook configured)", control.value) + return + + server_info = self._client.server_info + create_task( + run_hook( + hook, + event=f"source_{control.value}", + server_id=server_info.server_id if server_info else None, + server_name=server_info.name if server_info else None, + server_url=self._config.hook_server_url, + client_id=self._config.hook_client_id, + client_name=self._config.hook_client_name, + ) + ) + self._logger.info("Source control received: %s", control.value) + + async def _send_state( + self, state: SourceStateType, level: float, signal: SourceSignalType + ) -> None: + if not self._client.connected: + return + try: + client_any = cast("Any", self._client) + await client_any.send_source_state( + state=SourceStatePayload(state=state, level=level, signal=signal) + ) + except RuntimeError: + return + + async def _send_command(self, command: SourceClientCommand) -> None: + if not self._client.connected: + return + try: + client_any = cast("Any", self._client) + await client_any.send_source_command(command) + except RuntimeError: + return + + async def _send_chunk(self, pcm: bytes) -> bool: + if not self._client.connected: + return False + if not self._client.is_time_synchronized(): + return False + capture_timestamp_us = int(asyncio.get_running_loop().time() * 1_000_000) + try: + client_any = cast("Any", self._client) + await client_any.send_source_audio_chunk(pcm, capture_timestamp_us=capture_timestamp_us) + except RuntimeError: + return False + return True + + async def _stream_sine(self, streaming: asyncio.Event) -> None: + samples_per_frame = int(self._config.sample_rate * self._config.frame_ms / 1000) + phase = 0.0 + phase_step = 2.0 * math.pi * self._config.sine_hz / self._config.sample_rate + amplitude = 0.3 + frame_duration = self._config.frame_ms / 1000.0 + last_log = asyncio.get_running_loop().time() + frame_count = 0 + + sine_level = min(1.0, abs(amplitude) / math.sqrt(2)) + while True: + if not streaming.is_set(): + await asyncio.sleep(0.05) + continue + + pcm = bytearray() + for _ in range(samples_per_frame): + sample = int( + amplitude * math.sin(phase) * ((1 << (self._config.bit_depth - 1)) - 1) + ) + phase += phase_step + for _ in range(self._config.channels): + if self._config.bit_depth == 16: + pcm.extend(struct.pack("= 1.0: + self._logger.info("Sent %d source frames/sec", frame_count) + frame_count = 0 + last_log = now + await asyncio.sleep(frame_duration) + + async def _stream_linein(self, streaming: asyncio.Event) -> None: + try: + import sounddevice as sd + except Exception as err: # noqa: BLE001 + raise RuntimeError("sounddevice is required for line-in capture") from err + + if self._config.bit_depth == 24: + raise RuntimeError("Line-in capture does not support 24-bit PCM") + dtype = "int16" if self._config.bit_depth == 16 else "int32" + samples_per_frame = int(self._config.sample_rate * self._config.frame_ms / 1000) + last_log = asyncio.get_running_loop().time() + frame_count = 0 + queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=20) + loop = asyncio.get_running_loop() + + def _enqueue(data: bytes) -> None: + if queue.full(): + return + queue.put_nowait(data) + + def _callback(indata: Any, frames: int, time: Any, status: Any) -> None: # noqa: ARG001 + if status: + self._logger.debug("Line-in status: %s", status) + data = indata.copy().tobytes() + loop.call_soon_threadsafe(_enqueue, data) + + with sd.InputStream( + samplerate=self._config.sample_rate, + channels=self._config.channels, + dtype=dtype, + blocksize=samples_per_frame, + device=self._config.device, + callback=_callback, + ): + while True: + pcm = await queue.get() + level = calc_level(pcm, self._config.bit_depth) + if streaming.is_set(): + if await self._send_chunk(pcm): + await self._reporter.update(level, state=SourceStateType.STREAMING) + frame_count += 1 + now = asyncio.get_running_loop().time() + if now - last_log >= 1.0: + self._logger.info("Sent %d source frames/sec", frame_count) + frame_count = 0 + last_log = now + continue + await self._reporter.update(level, state=SourceStateType.IDLE) diff --git a/sendspin/source_utils.py b/sendspin/source_utils.py new file mode 100644 index 0000000..0a15b69 --- /dev/null +++ b/sendspin/source_utils.py @@ -0,0 +1,118 @@ +"""Helpers for source signal detection and reporting.""" + +from __future__ import annotations + +import array +import asyncio +import math +import sys +from collections.abc import Awaitable, Callable + +from aiosendspin.models.source import SourceClientCommand, SourceSignalType, SourceStateType + +SendStateFunc = Callable[[SourceStateType, float, SourceSignalType], Awaitable[None]] +SendCommandFunc = Callable[[SourceClientCommand], Awaitable[None]] + + +def calc_level(pcm: bytes, bit_depth: int) -> float: + """Calculate normalized RMS level for 16/32-bit PCM.""" + if bit_depth == 16: + values = array.array("h") + max_val = float((1 << 15) - 1) + elif bit_depth == 32: + values = array.array("i") + max_val = float((1 << 31) - 1) + else: + raise ValueError("Unsupported bit depth for level calculation") + + values.frombytes(pcm) + if sys.byteorder != "little": + values.byteswap() + if not values: + return 0.0 + acc = 0.0 + for sample in values: + acc += float(sample) * float(sample) + rms = math.sqrt(acc / len(values)) + return min(1.0, rms / max_val) + + +class SourceSignalReporter: + """Emit source state and started/stopped events based on signal level.""" + + def __init__( + self, + *, + threshold_db: float, + send_state: SendStateFunc, + send_command: SendCommandFunc, + hold_ms: float = 300.0, + ) -> None: + if not math.isfinite(threshold_db): + raise ValueError("threshold_db must be a finite number") + if not math.isfinite(hold_ms): + raise ValueError("hold_ms must be a finite number") + self._threshold_db = threshold_db + self._threshold = 10 ** (threshold_db / 20.0) + self._send_state = send_state + self._send_command = send_command + self._hold_seconds = max(0.0, hold_ms / 1000.0) + self._last_signal: SourceSignalType | None = None + self._last_state: SourceStateType | None = None + self._candidate_signal: SourceSignalType | None = None + self._candidate_since = 0.0 + + def update_vad( + self, *, threshold_db: float | None = None, hold_ms: float | None = None + ) -> None: + """Update VAD thresholds without resetting state.""" + if threshold_db is not None: + if not math.isfinite(threshold_db): + return + self._threshold_db = threshold_db + self._threshold = 10 ** (threshold_db / 20.0) + if hold_ms is not None: + if not math.isfinite(hold_ms): + return + self._hold_seconds = max(0.0, hold_ms / 1000.0) + self._candidate_signal = None + self._candidate_since = 0.0 + + def reset(self) -> None: + """Reset signal tracking state.""" + self._last_signal = None + self._last_state = None + self._candidate_signal = None + self._candidate_since = 0.0 + + async def update(self, level: float, *, state: SourceStateType) -> None: + """Update signal state and emit events if needed.""" + raw_signal = ( + SourceSignalType.PRESENT if level >= self._threshold else SourceSignalType.ABSENT + ) + previous_signal = self._last_signal + now = asyncio.get_running_loop().time() + + if raw_signal != previous_signal: + if self._candidate_signal != raw_signal: + self._candidate_signal = raw_signal + self._candidate_since = now + elif now - self._candidate_since >= self._hold_seconds: + if previous_signal is not None: + command = ( + SourceClientCommand.STARTED + if raw_signal == SourceSignalType.PRESENT + else SourceClientCommand.STOPPED + ) + await self._send_command(command) + self._last_signal = raw_signal + self._candidate_signal = None + self._candidate_since = 0.0 + + if self._last_signal is None: + self._last_signal = raw_signal + state_changed = self._last_state != state + signal_changed = self._last_signal != previous_signal + if state_changed or signal_changed: + await self._send_state(state, level, self._last_signal) + self._last_state = state From 61938c81788593c69d9815c53bb54703b301cd7e Mon Sep 17 00:00:00 2001 From: Rudy Date: Mon, 16 Feb 2026 13:54:19 +0100 Subject: [PATCH 2/2] refactor(daemon): move source imports to module scope --- sendspin/daemon/daemon.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/sendspin/daemon/daemon.py b/sendspin/daemon/daemon.py index 7752341..9b9e118 100644 --- a/sendspin/daemon/daemon.py +++ b/sendspin/daemon/daemon.py @@ -8,7 +8,7 @@ import signal from collections.abc import Callable from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, cast +from typing import Any, cast from aiohttp import ClientError, web from aiosendspin.client import ClientListener, SendspinClient @@ -18,7 +18,12 @@ ServerCommandPayload, ) from aiosendspin.models.player import ClientHelloPlayerSupport -from aiosendspin.models.source import SourceControl +from aiosendspin.models.source import ( + ClientHelloSourceSupport, + SourceControl, + SourceFeatures, + SourceFormat, +) from aiosendspin_mpris import MPRIS_AVAILABLE, SendspinMpris from aiosendspin.models.types import ( AudioCodec, @@ -32,11 +37,9 @@ from sendspin.audio_connector import AudioStreamHandler from sendspin.hooks import run_hook from sendspin.settings import ClientSettings +from sendspin.source_stream import SourceStreamConfig, SourceStreamer from sendspin.utils import create_task, get_device_info -if TYPE_CHECKING: - from sendspin.source_stream import SourceStreamer - logger = logging.getLogger(__name__) @@ -105,12 +108,6 @@ def _create_client(self, static_delay_ms: float = 0.0) -> SendspinClient: client_roles.extend([Roles.METADATA, Roles.CONTROLLER]) source_support = None if self._args.source_enabled: - from aiosendspin.models.source import ( - ClientHelloSourceSupport, - SourceFeatures, - SourceFormat, - ) - client_roles.append(Roles("source@v1")) controls = list(self._source_control_hooks().keys()) or None source_support = ClientHelloSourceSupport( @@ -357,7 +354,6 @@ async def _start_source_streaming(self) -> None: return if self._source_task is not None and not self._source_task.done(): return - from sendspin.source_stream import SourceStreamConfig, SourceStreamer device: str | int | None = self._args.source_device if isinstance(device, str) and device.isdigit():