From 33aa5089cab9c29b3bdcb9429450435ed2beed67 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 3 Sep 2026 10:31:26 -0500 Subject: [PATCH 1/4] Restructure README around what/how-to-run/how-to-contribute Split the developer-focused README into an end-user README, a new contributors.md for build/test/release guidance, and a wiki Advanced-Usage page for connection modes, output selection, logging, buffering, identity and stream hooks. The wiki is now the complete end-user reference beyond the README's quick start. Also removes every remaining squeezelite reference from docs and source comments (including --help output), and fixes several broken self-referential wiki links and one --help inaccuracy (there is no --daemonize long alias for -z) introduced while restructuring. --- .github/workflows/release.yml | 9 +- CMakeLists.txt | 1 + README.md | 1559 +---------------- contributors.md | 64 + docs/ROADMAP.md | 24 +- docs/wiki/Advanced-Usage.md | 137 ++ docs/wiki/Configuration.md | 2 +- docs/wiki/Controlling-the-Player.md | 15 +- docs/wiki/Getting-Started-on-Linux.md | 4 +- .../wiki/Getting-Started-on-a-Raspberry-Pi.md | 2 +- docs/wiki/Home.md | 22 +- docs/wiki/Installation.md | 15 +- docs/wiki/Running-as-a-Service.md | 8 +- docs/wiki/Troubleshooting.md | 6 +- docs/wiki/_Sidebar.md | 3 +- src/alsa_sink.h | 2 +- src/audio_sink.cpp | 2 +- src/audio_sink.h | 2 +- src/cli.cpp | 12 +- src/cli.h | 18 +- tests/cli_test.cpp | 8 +- 21 files changed, 321 insertions(+), 1594 deletions(-) create mode 100644 contributors.md create mode 100644 docs/wiki/Advanced-Usage.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a5f2173..95e32a9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -350,11 +350,12 @@ jobs: the minimum an arm64 Mach-O needs to execute at all, and carries no developer identity — so Gatekeeper refuses them until you say otherwise. Said here rather than only behind a link because it is worth knowing before you download; what to do about it is under - [macOS, and Gatekeeper](https://github.com/@REPO@/blob/@TAG@/README.md#macos-and-gatekeeper). + [the macOS installation guide](https://github.com/@REPO@/wiki/Installation#macos). - - [README](https://github.com/@REPO@/blob/@TAG@/README.md) — build, install, run, - [macOS, and Gatekeeper](https://github.com/@REPO@/blob/@TAG@/README.md#macos-and-gatekeeper), - and [the installer `.pkg`](https://github.com/@REPO@/blob/@TAG@/README.md#the-macos-installer-pkg) + - [README](https://github.com/@REPO@/blob/@TAG@/README.md) — what it is, how to run + it, and how to contribute + - [Contributors](https://github.com/@REPO@/blob/@TAG@/contributors.md) — development, + testing, CI, and release guidance - [Roadmap](https://github.com/@REPO@/blob/@TAG@/docs/ROADMAP.md) — what shipped and what is owed, item by item NOTES diff --git a/CMakeLists.txt b/CMakeLists.txt index 4118392..75f6c3b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -440,6 +440,7 @@ if(SENDSPIN_CLI_IS_TOP_LEVEL) # of it. install(FILES README.md + contributors.md LICENSE packaging/sendspin-cli.conf.example DESTINATION ${SENDSPIN_CLI_DOC_DIR} diff --git a/README.md b/README.md index e48840a..3922bcf 100644 --- a/README.md +++ b/README.md @@ -1,1544 +1,75 @@ -# sendspin-cpp-cli +# sendspin-cli -A headless **CLI / daemon audio player** built on the -[sendspin-cpp](https://github.com/Sendspin/sendspin-cpp) synchronized -audio-streaming library, taking its command-line and control ergonomics from -[squeezelite](https://github.com/ralph-irving/squeezelite). +## What is it? -> **Status: early scaffold.** This repository is being stood up as an *epic*. -> The initial task brings up the build and boots a sendspin client; feature work -> is tracked in [`docs/ROADMAP.md`](docs/ROADMAP.md). +`sendspin-cli` is a headless audio player for the +[Sendspin](https://github.com/Sendspin/spec) protocol. It appears on your network, a +Sendspin server finds it, and it plays synchronized audio with the rest of your group. -**New here?** The [wiki](https://github.com/Sendspin/sendspin-cpp-cli/wiki) is the -task-shaped version of this file — installing, a Raspberry Pi walkthrough, troubleshooting — -and on Linux [`scripts/get_started_linux.sh`](scripts/get_started_linux.sh) does the install -in one command. Those pages are authored in [`docs/wiki/`](docs/wiki) and mirrored to the -wiki tab on every push to `main`. +It runs on Linux and Apple-silicon macOS. On Linux, it can also run as a system service. -## What it is +> **Status: early scaffold.** The player works, but not every planned feature is +> available yet. See the [roadmap](docs/ROADMAP.md) for the current status. -Like squeezelite is a headless endpoint for Lyrion/Logitech Media Server, -`sendspin-cli` is a headless endpoint for the **Sendspin** protocol: it -advertises itself over mDNS (`_sendspin._tcp`), is discovered, and is driven by -a remote sendspin *controller* — while a small, squeezelite-style flag set -configures identity, audio output, discovery, logging, and daemonization. It also -listens on a **local control socket**, so `sendspin-cli pause` on the player's own -host drives it too — the deliberate addition to the squeezelite model. +## How can I run it? -- **Library:** sendspin-cpp (`player`, `metadata` and `controller` roles; FLAC / - Opus / PCM), pulled via CMake `FetchContent`. -- **Audio out:** an `AudioSink` seam — ALSA (the default Linux/Docker backend, - with `snd_pcm_delay()`-based sync feedback), PortAudio (the cross-platform one, - and the only way to make noise on macOS), and a null/stdout sink for - device-less containers. -- **Binary:** `sendspin-cli`. +### Linux and Raspberry Pi -## Build - -Requires **CMake ≥ 3.16**, a **C++20** compiler, and network access on the first -configure. sendspin-cpp is pulled in with `FetchContent` at a pinned tag, and it -fetches its own dependencies (ArduinoJson, micro-flac, micro-opus, IXWebSocket) in -turn. - -**The audio backends and mDNS are all optional and auto-detected.** Whichever of -libasound, libportaudio, libpulse and libpipewire is present gets compiled in; -where none is, the build falls back to the device-less sinks, so a -sound-card-less container still builds and runs. mDNS comes from `dns_sd.h` — -Bonjour on macOS, where it needs nothing installed, and -`libavahi-compat-libdnssd` on Linux. The configure output says what you got: - -``` --- sendspin-cli audio backends: null, stdout, alsa, portaudio, pulse, pipewire --- sendspin-cli mDNS: dns_sd (/usr/lib/x86_64-linux-gnu/libdns_sd.so) -``` +The quickest way to install a released build is the guided Linux installer: ```bash -sudo dnf install pkgconf alsa-lib-devel portaudio-devel pulseaudio-libs-devel pipewire-devel avahi-compat-libdns_sd-devel # Fedora / RHEL -sudo apt install pkg-config libasound2-dev portaudio19-dev libpulse-dev libpipewire-0.3-dev libavahi-compat-libdnssd-dev # Debian / Ubuntu -brew install portaudio pkgconf # macOS (no ALSA, and Bonjour is built in) +curl -fLO https://raw.githubusercontent.com/Sendspin/sendspin-cpp-cli/main/scripts/get_started_linux.sh +less get_started_linux.sh +chmod +x get_started_linux.sh +./get_started_linux.sh ``` -ALSA is found with CMake's own `find_package(ALSA)`; the other three ship no CMake -config module, so they are found with `pkg-config` (`portaudio-2.0`, `libpulse`, -`libpipewire-0.3` ≥ 0.3.64) — on macOS that is also the only thing that knows the -CoreAudio frameworks have to be linked too. A host without `pkg-config` gets its -own configure message saying so. +The script downloads the right release for your machine, verifies it, and guides you +through choosing an audio device. See the +[Linux quick start](https://github.com/Sendspin/sendspin-cpp-cli/wiki/Getting-Started-on-Linux) +or [Raspberry Pi guide](https://github.com/Sendspin/sendspin-cpp-cli/wiki/Getting-Started-on-a-Raspberry-Pi) +for the complete walkthrough. -The PipeWire minimum is a real one rather than caution: `PW_KEY_TARGET_OBJECT`, -which is how `-o pipewire:` names a node, arrived in 0.3.64, and an older -libpipewire would build and then quietly ignore the node. +### macOS -Pass `-DSENDSPIN_CLI_WITH_ALSA=OFF`, `-DSENDSPIN_CLI_WITH_PORTAUDIO=OFF`, -`-DSENDSPIN_CLI_WITH_PULSE=OFF`, `-DSENDSPIN_CLI_WITH_PIPEWIRE=OFF` or -`-DSENDSPIN_CLI_WITH_MDNS=OFF` to leave one out even where its library is available. -On a build that also has ALSA, dropping the two sound-server backends costs -nothing but the extras below: a PulseAudio or PipeWire host stays reachable -through ALSA's plugin PCMs as `-o alsa:pulse` and `-o alsa:pipewire`. Where the -ALSA backend is absent too — it is Linux-only, and `-DSENDSPIN_CLI_WITH_ALSA=OFF` -turns it off anywhere — those two *are* the only route to a sound server, and -dropping them leaves the build with no audio path but `null`. That is why the -configure summary only offers the `alsa:` way back where there is an ALSA backend -to serve it. +Download the Apple-silicon `.pkg` installer or tarball from +[Releases](https://github.com/Sendspin/sendspin-cpp-cli/releases), then follow the +[installation guide](https://github.com/Sendspin/sendspin-cpp-cli/wiki/Installation#macos). -`-DSENDSPIN_CLI_WERROR=ON` makes warnings fatal, for sendspin-cli's own three -targets and nothing else — the `sendspin` and GoogleTest trees fetched at -configure time are not ours to keep clean. It is off by default so that a fresh -diagnostic from a newer compiler cannot block a contributor who did not cause it; -CI turns it on, which is where the line is actually held. +### Start playing -> C++20 rather than C++17: sendspin-cpp's host build declares -> `target_compile_features(sendspin PUBLIC cxx_std_20)`, so the requirement -> propagates to anything that links it. +Once installed and configured, start a player from a terminal: ```bash -git clone https://github.com/Sendspin/sendspin-cpp-cli.git -cd sendspin-cpp-cli -cmake -B build -cmake --build build -./build/sendspin-cli --help +sendspin-cli -n living-room ``` -To build against a different version of the library: +It advertises itself on the local network and waits for a Sendspin server to connect. +For a Linux system-service installation, the quick-start guide explains how to enable +and check the service. -```bash -cmake -B build -DSENDSPIN_GIT_TAG=v0.7.0 -``` - -## Install +Common local controls are available from the same host: ```bash -cmake -B build # the prefix is chosen here -cmake --build build -sudo cmake --install build --component sendspin-cli -``` - -``` -/usr/local/bin/sendspin-cli -/usr/local/lib/systemd/system/sendspin-cli.service # Linux only -/usr/local/lib/sysusers.d/sendspin-cli.conf # Linux only -/usr/local/share/doc/sendspin-cli/README.md -/usr/local/share/doc/sendspin-cli/LICENSE -/usr/local/share/doc/sendspin-cli/sendspin-cli.conf.example +sendspin-cli status +sendspin-cli pause +sendspin-cli vol 40 ``` -**`--component sendspin-cli` is not garnish.** ArduinoJson, fetched by sendspin-cpp in -turn, installs its headers and CMake export files unconditionally — no option to turn -that off the way IXWebSocket's and GoogleTest's have — so a plain `cmake --install` -stages 143 files more than the payload's own, none of which anything here links or -builds against. Naming the component is how you say which install rules are this -project's. - -**The prefix is baked in at *configure* time**, because the unit's `ExecStart` is an -absolute path: `cmake -B build -DCMAKE_INSTALL_PREFIX=/usr` moves both the binary and -the path the unit names, while `cmake --install --prefix` relocates the files around a -unit that still points at the old one. Reconfigure rather than redirect the install. - -The unit goes in `lib/systemd/system` and not in a multiarch `libdir`: a unit file is -architecture-independent, and systemd reads `/usr/lib/systemd/system` and -`/usr/local/lib/systemd/system` — never `lib/x86_64-linux-gnu/systemd/system`. That is -also why the default prefix needs nothing copied by hand. The account declaration -beside it is in `lib/sysusers.d` for the same two reasons: `systemd-sysusers` searches -`/usr/local/lib/sysusers.d` alongside `/usr/lib/sysusers.d`, and a list of users has no -architecture either. - -To stage the same payload elsewhere — a tarball, a container image, an installer — -give `cmake --install` a `DESTDIR` instead of a different prefix, and every path -inside it is the path the file installs to: - -```bash -DESTDIR=/tmp/stage cmake --install build --component sendspin-cli -``` - -That is exactly what CI publishes: see [CI](#ci). One ordering trap if you do both in -one build tree: a component install writes `install_manifest_sendspin-cli.txt` into -`build/`, so the `sudo` install above leaves a root-owned one and an unprivileged -`DESTDIR` install afterwards fails trying to rewrite it — on the CMake versions that -rewrite it unconditionally, which is most of them. Stage first, install second, which -is the order CI uses for exactly this reason. - -### The systemd unit - -```bash -sudo systemd-sysusers -sudo systemctl daemon-reload -sudo systemctl enable --now sendspin-cli -systemctl status sendspin-cli -journalctl -u sendspin-cli -f -``` - -One system unit, with sane defaults and nothing to fill in. **`systemd-sysusers` is not -optional** — it creates the account the unit runs as, and the unit does not start -without it; the end of this section says what that account is and what it may reach. -It runs the player in the **foreground** under `Type=simple`, so the log goes to the -journal rather than to a file something has to rotate — `-z` and `-f` would both be -working around the supervisor. `Type=forking` with `PIDFile=` pointing at `-P` is the -shape for a supervisor with no journal, and `Type=notify` is not available at all: -`sd_notify` is not wired up. - -Two flags are on the `ExecStart` line, and both are there because a system unit has -neither of the environment variables a default path would come from: - -| Unit directive | Flag | Without the pair | -|---|---|---| -| `RuntimeDirectory=sendspin-cli` | `--control-socket /run/sendspin-cli/control.sock` | no `$XDG_RUNTIME_DIR`, so no control socket — one warning, and the player carries on | -| `StateDirectory=sendspin-cli` | `--state-dir /var/lib/sendspin-cli` | no `$XDG_STATE_HOME`, so volume, mute and the static delay are forgotten every restart | - -**Expect to set `output` before it plays anything.** A system unit has no user session, -so there is no PipeWire or PulseAudio for ALSA's `default` PCM to follow, and the -default that works from your shell usually fails under `systemctl` — a device that will -not open exits 1, and `Restart=on-failure` then retries it every five seconds. Run -`sendspin-cli -l`, pick the card, and put `output = hw:1,0` (or whatever it names) in -the config file below. - -`-o pulse` and `-o pipewire` are no way round that: a per-user sound server puts its -socket in that user's `$XDG_RUNTIME_DIR`, which this unit's own account cannot reach — -`ProtectHome=yes` aside, it is a different user. They fail at startup rather than -silently, naming the server they could not reach. Point `PULSE_SERVER` at a socket the -service account can open, run the server in system mode, or name the card directly. - -**Configure it in `/etc/sendspin-cli.conf`**, not by editing the unit — every config -key is a long flag name, so there is nothing the `ExecStart` line can say that the -file cannot. The installed `sendspin-cli.conf.example` is annotated for it. One -consequence to know, since the command line beats the file per option: `state-dir` -and `control-socket` in a config file are *silently ignored* under this unit, because -the unit passes both. Setting `control-socket` to the same path the unit uses is still -worth doing — it is what lets a subcommand find the socket with no flags. - -**The subcommands need `sudo` here.** The control socket is mode `0600` and belongs to -the service account, so an unprivileged shell cannot connect to it — while root can, -because root is not subject to the mode: - -```console -$ sudo sendspin-cli status --control-socket /run/sendspin-cli/control.sock -``` - -**It runs as `sendspin-cli`, an unprivileged account, and creating it is the one -step installing cannot do for you.** The unit names `User=sendspin-cli` — no home, -no shell — and a tarball has no `postinst`, so the declaration ships beside the unit as -`lib/sysusers.d/sendspin-cli.conf` and one command turns it into an account: - -```console -$ sudo systemd-sysusers -Creating group 'sendspin-cli' with GID 997. -Creating user 'sendspin-cli' (Sendspin audio player) with UID 997 and GID 997. -``` - -It is idempotent, so running it twice is free. Skip it and the unit does not start at -all — `systemctl status` says `status=217/USER`, which names the cause: - -``` -sendspin-cli.service: Main process exited, code=exited, status=217/USER -``` - -The fragment carries two lines, and they are owed **together**: the account, and its -membership of `audio`. A `sendspin-cli` in no `audio` group is a player that starts and -cannot open a device, because `/dev/snd` is `root:audio` mode `0660`. That is the whole -reason this is a shipped declaration rather than a `useradd` line in this file — one -artifact, both halves, or neither. If you manage accounts with your own tooling, the -equivalent is `useradd --system --no-create-home -G audio sendspin-cli`. - -`DynamicUser=` looks like it would avoid all of this and does not: it hands the player a -uid in no supplementary group at all, which deafens the ALSA backend. - -**Upgrading from a version that ran as root needs nothing done to -`/var/lib/sendspin-cli`.** `StateDirectory=` chowns the directory it finds as well as -the one it creates, recursively, so a root-owned state file from an earlier install -becomes the new account's on the first start and the remembered volume, mute and static -delay carry over. systemd has documented that since v235 — the same release this unit -needs anyway — and CI plants a root-owned `/var/lib/sendspin-cli` on every Linux leg and -reads the value back out of it afterwards. - -**What is hardened, and what is not.** The unit carries a hardening block — -`ProtectSystem=strict`, `NoNewPrivileges=`, an empty `CapabilityBoundingSet=`, -`RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6`, `SystemCallFilter=@system-service` -and the `Protect*=` family — and every directive in it was run rather than copied from a -list. Read the unit: each is commented where it sits. On systemd 255 `systemd-analyze -security` puts the result at **1.8 OK**, against **9.6 UNSAFE** for the root unit this -replaces; the Linux CI legs print the score on every run rather than asserting it, since -pinning a number would make an unrelated systemd release fail a build for rewording. - -**One configuration stops working, and it is worth checking before you upgrade.** -Under `ProtectSystem=strict` a `logfile` or `pidfile` in `/etc/sendspin-cli.conf` -pointing outside `/run/sendspin-cli` and `/var/lib/sendspin-cli` is refused — `cannot -open logfile /var/log/sendspin-cli.log: Read-only file system`, on every restart, -rather than a player that logs nowhere in silence. Neither key is the shape for this -unit anyway, since journald already has stderr and `-z`/`-f` are for a supervisor -without one. If you want a logfile regardless, a drop-in is the way back: - -```ini -[Service] -ReadWritePaths=/var/log -``` - -The block wants systemd **247** in full — `ProtectProc=` is its newest directive — -while the unit itself still starts on 236. Below 247 the shortfall is one line and a -log message: systemd warns `Unknown key name 'ProtectProc' … ignoring` and runs the -unit with the rest, which was checked on 245. - -Four directives are deliberately *absent*, because they gate what the ALSA backend -reaches and a machine with no sound card cannot tell you whether they break it: -`PrivateDevices=`, `DeviceAllow=`, `ProcSubset=pid` and `RestrictRealtime=`. Each of -them passes every check CI makes, which is exactly why passing proves nothing about -them. They are tracked in [`docs/ROADMAP.md`](docs/ROADMAP.md) item 10. - -**To change any of it, use a drop-in** rather than editing the installed unit, which an -upgrade overwrites: - -```bash -sudo systemctl edit sendspin-cli -``` - -An `ExecStart=` in a drop-in has to be cleared first (`ExecStart=` on its own line, -then the replacement), which is systemd's rule for every list-valued directive rather -than anything about this unit. - -## Run - -```bash -# Advertise over mDNS and wait to be found — the usual way to run it -./build/sendspin-cli -n living-room - -# Dial a server explicitly, retrying until it answers -./build/sendspin-cli -s 192.168.1.10 - -# Discover a server and dial that instead -./build/sendspin-cli -s mdns: - -# Debug logging, in the foreground. Every line is " : ", ours -# and the library's alike, so one grep reaches either half -./build/sendspin-cli -d debug 2>&1 | grep ' mdns:' - -# As a daemon: detached, with a locked pidfile and a logfile -./build/sendspin-cli -z -P /run/sendspin-cli.pid -f /var/log/sendspin-cli.log - -# ...and drive that player from its own host -./build/sendspin-cli status -./build/sendspin-cli pause -./build/sendspin-cli vol 40 -``` - -### The two connection modes - -The protocol has two, and they are **mutually exclusive** — the spec's rule, not a -preference here: - -> Do not advertise `_sendspin._tcp` if the client plans to initiate the connection. - -**Server-initiated (the default).** `sendspin-cli` advertises `_sendspin._tcp` on -its `--port`, with the required TXT `path=/sendspin` and a TXT `name`, and waits -for a server to dial it. Nothing needs configuring on either side. - -```console -$ sendspin-cli -n living-room -sendspin-cli 0.1.0 listening on port 8928 as "living-room" (output: default) -mDNS: advertising _sendspin._tcp. as "living-room" on port 8928 (path /sendspin) -``` - -The instance name comes from `--mdns-name`, falling back to `-n`, falling back to -this host's name. The name that is *logged* is the one that actually registered — -the mDNS daemon renames on a collision, so two players called `living-room` will -not fight over it. `--no-mdns` turns the advertisement off without switching modes. - -**Client-initiated (`-s`).** Any `-s` makes this player the one dialling, so the -advertisement is suppressed and the run says so: - -```console -$ sendspin-cli -s 192.168.1.10 -Not advertising _sendspin._tcp: -s makes this player the one initiating the connection, -and the Sendspin spec forbids advertising while it is -``` - -There is deliberately no flag that turns both on together. `--mdns-name` alongside -`-s` warns that it is unused rather than failing — it names an advertisement the -mode has already ruled out. - -`-s` takes an address, or `mdns:` to go and find one: - -```bash -./build/sendspin-cli -s 192.168.1.10 # a host, port 8927 assumed -./build/sendspin-cli -s music.local:9000 # host and port -./build/sendspin-cli -s ws://music:9000/sendspin # a full URL -./build/sendspin-cli -s "[2001:db8::1]:8927" # IPv6 must be bracketed -./build/sendspin-cli -s mdns: # discover any server -./build/sendspin-cli -s "mdns:Music Assistant" # ...or one by its advertised name -``` - -**A `-s` URL may carry userinfo, and this player does not send it.** `-s -ws://user:token@host:8927/sendspin` is accepted and dials `host:8927/sendspin` — the -credentials are dropped before the handshake, because the spec authenticates in the -handshake (pairing and a PSK) and neither sendspin-cpp nor the IXWebSocket transport under -it turns URL userinfo into an `Authorization` header. Verified on the wire: the upgrade -request carries `Host`, `Upgrade`, `Sec-WebSocket-*` and `Origin`, and nothing else. So if -something in front of your server wants HTTP Basic, a `-s` URL is not how to give it to it — -and accepting a credential it cannot send is a wrong the player owes a fix, tracked in -[docs/ROADMAP.md](docs/ROADMAP.md#6-daemonization-and-logging--shipped). - -What userinfo does do is get written down, so **the log masks it**: `Connecting to -ws://user:***@host:8927/sendspin`, and the same in any complaint about a URL that did not -parse. Two limits on that are worth knowing. Lines tagged `sendspin.*` are the library's own -and print the URL in full — it logs what it dials through macros with no sink hook, so -nothing here can reach them. And masking reads the URL the way a URL parser does, so a -userinfo field containing an unencoded `/`, `?` or `#` — a raw base64 secret, say — ends the -authority early and is *not* masked; percent-encode those (`%2F`, `%3F`, `%23`). Both are in -[docs/ROADMAP.md](docs/ROADMAP.md#6-daemonization-and-logging--shipped). - -Either way `argv` is not something the player controls: `ps` shows a running process's -command line to every local user on the box, so a URL you would rather not publish belongs -in the config file, where the file's own `0600` is the protection. - -`mdns:` is reserved **before the first colon only**, the same way `-o` reads -`:`, so every address form still works — `hifi:8927` is a host -and a port, and a bare `-s mdns` is still a host called `mdns`. Discovery is not a -bare `-s` because `-s` takes a required argument, so a bare one would swallow the -next word. - -Discovery browses `_sendspin-server._tcp`, resolves each instance to an address, -and dials `ws://:` built from the server's own TXT `path`. An -instance advertising no `path`, or one not starting with `/`, is skipped and said -so at `debug`. A non-link-local IPv4 wins over IPv6; an IPv6-only server yields a -bracketed URL. The browse stays open, so a server that appears later is picked up -without a restart. - -Among several servers, the one this player last completed a handshake with wins, -and otherwise the first to resolve. That works because the `_sendspin-server._tcp` -instance label *is* the protocol `server_id`, so the preference is decidable before -anything is dialled. It is remembered in `$XDG_STATE_HOME/sendspin-cli/last-server` -(falling back to `~/.local/state/...`); a process with neither variable set, or an -unwritable directory, simply does not remember and says so at `debug`. - -```console -$ sendspin-cli -s mdns: -mDNS: found server "OraobU4l…" (name: Music Assistant) at ws://10.0.2.8:8927/sendspin -mDNS: found server "oGsvjWZw…" (name: Music Assistant) at ws://10.0.1.6:8927/sendspin -Connecting to ws://10.0.1.6:8927/sendspin (server "oGsvjWZw…") -- chosen because it is -the last server whose handshake completed -``` - -**Retries are part of the mode.** In this direction nothing else re-establishes the -link — per the spec, "servers cannot reclaim clients by reconnecting" — so `-s` -retries on its own: 1 s, doubling to a 30 s ceiling, reset when a handshake -completes and restarted from the floor when a connection drops. The ceiling matches -the library's own 30 s establish timeout. Redials are paced from the last dial -rather than from "not connected yet", because an attempt in flight reads as not -connected and redialling over it would cancel it. - -### When the build has no mDNS - -mDNS is optional and auto-detected, like the audio backends. Without it the player -still builds, starts and plays — it just has to be told where its server is: - -```console -$ cmake -B build --- sendspin-cli mDNS: none - -$ ./build/sendspin-cli -This build has no mDNS support, so it cannot be discovered: point a server at -ws://:8928/sendspin, or dial one with -s. See docs/ROADMAP.md. - -$ ./build/sendspin-cli -s mdns: -error: -s 'mdns:': this build has no mDNS support, so it cannot discover a server. -Rebuild with dns_sd.h available (libavahi-compat-libdnssd-dev on Debian/Ubuntu, -avahi-compat-libdns_sd-devel on Fedora), or give -s an address. -``` - -Discovery is refused at *parse* time rather than starting and quietly finding -nothing, which is how `-o` already treats a backend the build lacks. - -### Choosing an output - -`-l` lists what this host can play through, and for each ALSA PCM the rates, -formats, and channel counts it actually accepts: - -``` - hdmi:CARD=NVidia,DEV=0 - HDA NVidia, HDMI 0 - rates: 32000 44100 48000 88200 96000 176400 192000 - formats: S16_LE S32_LE - channels: 2 4 6 8 -``` - -Only the four formats `sendspin-cli` can emit are ever listed — `S8`, `S16_LE`, -`S24_3LE`, `S32_LE` — since anything else is a capability this player cannot -reach. A plug-style PCM (`default`, `plughw:`, most sound-server PCMs) reports -nearly everything because the plug layer converts, so its list says little about -the hardware behind it. A device another process holds exclusively is reported as -in use rather than dropping out of the listing. - -On a host with PortAudio, `-l` also lists its output devices — index, name, host -API, output channel count and default rate, with the system default marked and -input-only devices left out: - -``` - idx name host API out ch default rate - 0 Odyssey G95NC Core Audio 2 ch 48000 Hz - rates: 22050 32000 44100 48000 88200 96000 176400 192000 - formats: paInt8 paInt16 paInt24 paInt32 - channels: 1 2 - 2 MacBook Pro Speakers Core Audio 2 ch 48000 Hz (system default) - rates: 22050 32000 44100 48000 88200 96000 176400 192000 - formats: paInt8 paInt16 paInt24 paInt32 - channels: 1 2 -``` - -Both backends report the same three lines, asked the same way a stream would ask — -only the format spelling is each backend's own. The rate on a PortAudio device's -own line is its *default*; the rates under it are what it will take. - -`-o` reads its argument in three steps, in this order: - -1. a backend name on its own — `null` discards audio, `stdout` (or `-`) writes raw - interleaved PCM to standard output, `portaudio` follows whatever this host's - default output currently is, `pulse` the PulseAudio server's own default sink, - and `pipewire` wherever the PipeWire graph routes a playback stream. These mean - the same thing on every build that has them, even where ALSA ships a PCM of the - same name; -2. `:`, split on the **first** colon, where the backend is one of - the names the build reports (`null, stdout, alsa, portaudio, pulse, pipewire`). - The split is on the first colon because ALSA device names carry their own, so - `-o alsa:hw:2,0` is the `alsa` backend playing `hw:2,0`; -3. anything else is an ALSA PCM name, which is squeezelite's model — so `-o hw:2,0` - and `-o default` keep working with no prefix at all. This step is deliberately - ALSA-only: the other backends *do* enumerate their devices, so letting a bare - name reach one would make the same command line mean different things per host. - -```bash -./build/sendspin-cli -l # what this host can play through -./build/sendspin-cli -o default # follow the system config (PipeWire/Pulse) -./build/sendspin-cli -o hw:2,0 # a card directly, bypassing the sound server -./build/sendspin-cli -o plughw:2,0 # same, letting ALSA convert rate/format -./build/sendspin-cli -o alsa:hw:2,0 # the same card, naming the backend explicitly -./build/sendspin-cli -o portaudio # this host's default output (what macOS wants) -./build/sendspin-cli -o portaudio:2 # a PortAudio device by index, as -l prints it -./build/sendspin-cli -o "portaudio:MacBook Pro Speakers" # ...or by name -./build/sendspin-cli -o pulse # the PulseAudio server's default sink -./build/sendspin-cli -o pulse:alsa_output.pci-0000_00_1f.3.analog-stereo # a named sink -./build/sendspin-cli -o pipewire # let the PipeWire graph route it -./build/sendspin-cli -o pipewire:alsa_output.usb-Topping_D10s # a named node -./build/sendspin-cli -o alsa:pulse # the same server through ALSA's plugin PCM -./build/sendspin-cli -o null # no sound card needed at all -``` - -A PortAudio device name is matched in full and case-insensitively. The **name is -the form worth writing down**: PortAudio numbers devices as it walks each host -API, so an index shifts as devices come and go. A name matching more than one -device is refused, naming the candidates, rather than guessed at — two host APIs -can offer the same card under the same name. - -A PulseAudio sink or a PipeWire node is named exactly as `-l` prints it, and the -name is passed to the server unchanged — so it is the *server* that resolves it, at -every stream, and a sink that appears after startup is picked up by the next track -without a restart. - -#### `-o pulse` and `-o pipewire` changed meaning in this release - -Both are also the names of real ALSA plugin PCMs, on exactly the hosts these -backends target. Before the native backends existed, `-o pulse` fell through step 3 -and opened `libasound2-plugins`' PulseAudio PCM; now step 1 claims the name and it -reaches the native backend instead. - -That is deliberate — on those hosts the native backend is the better answer, for -the reasons below — but it is a working command line changing what it does on -upgrade, so: - -- **`-o alsa:pulse` and `-o alsa:pipewire` are the way back**, unambiguously, and - they work on every build with the ALSA backend, native backends or not; -- `-l` leaves `pulse` and `pipewire` out of its ALSA PCM list on a build that - shadows them, because listing them would name devices `-o` can no longer reach — - and says so, with the `alsa:` form, above the list; -- a build *without* the native backends answers `-o pulse:` by naming both - `-DSENDSPIN_CLI_WITH_PULSE` and the `alsa:pulse` route, rather than sending you - off to rebuild for a path that already works. - -What the native backends buy over the plugin PCM, which is why the trade is worth -making: - -- **The sinks and nodes are enumerable.** Through the plugin there is one ALSA - hint and no more, so a sink is chosen with `PULSE_SINK` or `~/.asoundrc` rather - than with `-o`. -- **Honest sync feedback.** The ALSA backend derives its playout timing from - `snd_pcm_delay()`, which through the plugin reports the plugin's own buffering - rather than the server's. `pa_stream_get_latency()` and PipeWire's `pw_time` - answer for the real path — and this is a *synchronised* multi-room player, so - that is not cosmetic. -- **A named stream.** The host's mixer shows `sendspin-cli` and can route it - per-application, where every plugin stream is just "ALSA plug-in". - -`-o pipewire` is a native backend rather than a second way to spell `-o pulse` -because only a native client can name a *node*: `pipewire-pulse` presents sinks, -which is a compatibility view of the graph rather than the graph. It reaches no -host libpulse cannot — what it buys is on the far side of the socket. - -The default `-o` is unchanged on every host that had one: `default` where the ALSA -backend is present, then `portaudio`, then `pulse`, then `pipewire`, and `null` -where none is. ALSA wins wherever it is built, because on Linux everything else is -a layer over it or beside it and going direct is one layer fewer — and `default` -already reaches whichever sound server the host runs. - -Volume is applied in software on every backend, sharing one Q32 fixed-point -implementation, so a stream sounds the same whichever plays it — and works the same -through PipeWire's ALSA plugin as through bare hardware. It is deliberately *not* -handed to PulseAudio or PipeWire as a stream volume: the curve below is the spec's -rather than either server's, stacking a server gain on the software one would square -the taper, and a gain the host's mixer could move behind our back would leave the -player reporting a volume the speaker is not at — which group volume is derived -from. The ALSA hardware mixer is a follow-up, for the same one-path-or-the-other -reason. - -The one audible difference between backends: PortAudio scales in its audio -callback, so a volume change also reaches audio already buffered, where ALSA, -PulseAudio and PipeWire scale on the way in and so only affect what has not been -handed over yet. - -The curve is the one the spec names, `amplitude = (volume / 100)^1.5`, because a -Sendspin volume is **perceived loudness** rather than amplitude — volume 50 is -meant to sound half as loud as 100, and that exponent is what makes the number on -a controller's slider mean that. It is deliberately not upstream's `^2`, which is -about 3 dB quieter at volume 50 and 6 dB at 25. - -A change is applied over a **20 ms ramp** rather than as a jump, which the spec asks -for: *"to avoid audible clicks, clients SHOULD apply volume changes over a short -ramp."* Jumping the gain between one sample and the next is a step in the waveform, -and a step is what a click is — most obvious on a mute, which is the largest jump -there is. The gain moves at a fixed slew rate, so a full-scale change takes the whole -20 ms and a small one is proportionally quicker, and it steps **per frame** so every -channel of a frame is scaled by the same gain. A stream *starts* at its gain rather -than fading into it, so a volume restored from the state file produces no fade at the -top of the first track. `-o null` and `-o stdout` are unaffected: they record the -volume without applying it, so there is nothing there to ramp. - -### Buffering, and what gets advertised - -`--buffer-ms ` (10–2000, default 100) is how much audio the output backend -keeps queued — one figure for every backend rather than squeezelite's ALSA-only -`-a`, whose `:

::` grammar would mean something different per backend -here. ALSA divides it into five periods; PortAudio and PipeWire make it the ring -size, where a figure smaller than one device buffer or graph quantum is raised to -the floor and says so at `debug`; PulseAudio makes it the server's own queue -(`tlength`), which is the only buffer in that path and exactly what its latency -query answers about. A device-less sink (`null`, `stdout`) has nothing to size and -ignores it. - -The formats advertised to the server are **derived from the device**, not fixed: -`sendspin-cli` probes what `-o` selected — the same probe `-l` prints — and crosses -it with what each codec can carry. FLAC and PCM get every rate and depth the device -takes; OPUS gets 48 kHz / 16-bit only, because the decoder writes `int16_t`. The -result is logged at startup, and a device that cannot be probed is advertised -permissively rather than not at all. - -Two limits worth knowing, since both make that list a snapshot: - -- `-o portaudio` re-resolves the host's default output at every stream, so the - advertisement describes whichever device was default when the player started. -- ALSA's `default` is usually PipeWire's plugin, so the probe describes what the - *plugin* accepts, not the card behind it. -- `-o pulse` and `-o pipewire` advertise everything `sendspin-cli` can emit, and - that is the honest answer rather than a shortcut: both servers resample and - reformat into whatever the sink is running at, so what a sink *accepts* says - nothing about the hardware behind it. - -Either way, a format the device then refuses is reported loudly — naming the -device, the format, and the fact that the stream's audio is being discarded — -rather than leaving a player that looks healthy and plays nothing. - -`--audio-format ` (e.g. `flac:48000:24:2`) pins a -preferred format on top of that derived list — the way to hold a fussy DAC at -the one shape it is happy in. It is a *reorder*, not a narrowing: the pinned -entry moves to the front, which is where a spec-following server picks, and -everything else the device takes is still offered behind it. A pin that -derived list does not carry **refuses to start** — playing something else -instead is the failure the flag exists to prevent — and `-l` shows what the -device itself reports, which is not the same set. The advertisement carries a -single channel count — stereo where the device takes it, its narrowest count -otherwise — so a mono or multichannel pin is refused on any device that also -takes stereo. Codecs are `flac`, `opus` and `pcm`; the grammar is the Python -`sendspin-cli`'s, plus `opus`. An `opus` pin is refused outright at anything -but 48 kHz / 16-bit / at most two channels, since that is the only shape it is -ever advertised in. - -### Running as a daemon - -`-z` forks once, `setsid()`s away from the controlling terminal, `chdir()`s to `/` -so it does not pin a mount point, and points stdin and stdout at `/dev/null`. The -parent exits `0` immediately, so the shell comes straight back. - -```bash -sendspin-cli -z -P /run/sendspin-cli.pid -f /var/log/sendspin-cli.log -``` - -**Which failures reach the terminal, and which only reach the log.** Everything -cheap and fallible happens *before* the fork, so it can still be reported to the -shell that is watching: the flag parse, opening the `-f` logfile, and a probe of -the `-P` pidfile. Those exit `1` at the terminal exactly as a foreground run does. - -Everything after the fork — the output device, the WebSocket server, mDNS — can -only report into the log, because the terminal has already been given a `0`. A -device that is busy or absent therefore looks like a clean start and then says why -in the logfile: - -```console -$ sendspin-cli -z -f /tmp/s.log -o portaudio:999 ; echo $? -0 -$ cat /tmp/s.log -2026-08-10T21:41:48Z E audio: -o portaudio:999: no device at that index -- indices run 0-2 here, and -l lists the ones -o can reach -``` - -A fatal error like that one is stamped and tagged like every other line, but it is **not** -gated by `-d`: `-d none` means "do not narrate", not "exit without saying why". - -That boundary is why `-z` **without** `-f` warns. What goes to `/dev/null` is not -just the running commentary: it is the answer to "why did my daemon not come -up?" — and the non-zero exit is unreachable too, because the parent already -returned `0`. A supervisor sees a clean start followed by nothing at all. It -still starts, since a supervisor that captures nothing is a legitimate way to -run it, but that silence is indistinguishable from a crash, which is why the -warning exists. `-z` with `-o stdout` is refused outright rather than warned -about, since a daemon's stdout *is* `/dev/null` and the PCM would be discarded. - -Under `-z`, a relative `-P` or `-f` path is resolved against the directory you ran -it from, before the `chdir()`. A foreground run leaves relative paths exactly as -typed. - -**The pidfile is a lock, not just a file.** `-P` holds it under an exclusive -`flock()` for the process's whole life, so a second instance is refused by name: - -```console -$ sendspin-cli -z -P /run/sendspin-cli.pid -f /var/log/sendspin-cli.log -error: another sendspin-cli is already running -- it holds the lock on /run/sendspin-cli.pid -``` - -That also makes stale files a non-issue. A process killed with `SIGKILL` has its -descriptor closed by the kernel, so the lock is simply gone: the next start -truncates the leftover file and writes its own pid, with no cleanup step and no -liveness probe. Nothing ever parses the old contents, which is what stops a -recycled pid being read as a live instance. The file is removed on every clean -exit path. - -Two things to know about *where* the pidfile goes. Keep it on a **local -filesystem** — `flock` is emulated over NFS and is not dependable over SMB — which -in practice means `/run` (or `/var/run`). And `/run` is `root`-owned, so a daemon -started as a normal user wants a path it can write, such as -`$XDG_RUNTIME_DIR/sendspin-cli.pid`. - -**systemd.** A forking daemon wants `Type=forking` with `PIDFile=` pointing at the -same path as `-P`; the foreground default suits `Type=simple`, which is usually the -better choice under a supervisor that already captures stderr. That is what the unit -this project installs does — see [The systemd unit](#the-systemd-unit), where neither -`-z` nor `-P` appears. - -### The local control channel - -The player listens on a **Unix socket**, and the same binary is its own client: - -```console -$ sendspin-cli status -name: living-room -server: Music Assistant (connected) -state: playing -stream: receiving -track: Nils Frahm - Says -position: 2:05 / 9:03 -group volume: 55 -player volume: 80 -static delay: 0 ms -output: default (48000 Hz / 2 ch / 16-bit) - -$ sendspin-cli pause -$ sendspin-cli vol 40 -$ sendspin-cli seek-rel -30000 -$ sendspin-cli delay 250 -``` - -Every transport verb the protocol has, one subcommand each — `play`, `pause`, -`stop`, `next`, `prev`, `vol`, `mute`, `seek`, `seek-rel`, `repeat`, `shuffle`, -`switch` — plus two that go nowhere near the server: `status`, and `delay`. -`--help` lists them all with their arguments. - -**Four fields are the server's word, and can lag what is true** — which is why the -block ends with a `note:` line saying so. `state`, `position`, `repeat` and -`shuffle` all come from the server's last report, and the spec does not oblige it -to resend them after acting. Observed against a real server: `shuffle` read `off` -for minutes while it was demonstrably shuffling, and the position climbed straight -through seeks that audibly worked. If you have just changed something and the -figure has not moved, that is the likely reason — not a failed command. - -`position` additionally says `(estimated)` while playing, because the library -interpolates forward from the last progress the server sent. After a seek that the -server does not re-report, the estimate drifts by however far you jumped. Paused, -it is the server's own snapshot and carries no marker. - -`player volume` is the gain **this box's output** is applying, and it says -`(default; no server has set it)` until a server sends a volume command. The -qualifier is how you tell "nobody has set this" from a server that deliberately -chose full output. - -That figure is also what the server is told, from the first message — which the -spec requires and which matters more than it looks: **group volume is the average -of the players' volumes**, and setting group volume applies a *delta* against it. -A player that reported a volume it was not applying would skew the group reading -for every controller, and make the next group volume change land wrong by exactly -that error. - -Two `status` lines are worth reading together. `state` is the **group's** transport -state, from the metadata `playback_speed`, and reads `unknown` rather than guessing -when the server has sent no progress. `stream` is whether audio is arriving at -**this** endpoint, which is a different fact — a player dropped from the group loses -it while the group plays on. A `stream: receiving` line with no format after the -device name is the case where the device *refused* the stream's format and its audio -is being discarded; the log says so loudly at the same moment. - -**`delay` is the one subcommand that changes *this* box.** It sets the player role's -`static_delay_ms`, 0–5000: **how much latency this endpoint's hardware adds after the -audio port** — an amplifier, an external speaker, a DSP. - -Note the direction, because it is the opposite of what the name suggests. This is not -"play this speaker later"; it is "my gear is *already* this far behind". The sync task -**subtracts** the figure from every chunk's timestamp, so the player hands audio to the -device that much **earlier**, and the sound then lands on the timestamp the server meant. -The spec puts it as: 0 "means audio exits the device's audio port at the timestamp", and -the value "compensates for additional delay beyond the port". So if this speaker sounds -250 ms *late* against the rest of the group, `delay 250` is the fix: - -```console -$ sendspin-cli delay 250 # my amp adds 250 ms, so hand audio over 250 ms early -$ sendspin-cli status | grep 'static delay' -static delay: 250 ms -$ sendspin-cli delay 0 # off again -``` - -Three things follow from it being local rather than a transport command. It works with -**no server connected**, exactly as `status` does — nothing about it is sent, so there -is nothing for a missing connection to stop. It is **remembered across restarts**, which -the spec requires of a client, so you set it once per speaker. And the player still tells -the server, because the library republishes `client/state` when the value changes — the -server needs it to work out how far ahead to send audio. - -Out-of-range values are refused rather than clamped: `delay 5001` exits 1 and names the -bound. The library would quietly take it down to 5000 and report success, which would -mean a delay nobody asked for. - -One caveat: changing it mid-stream re-times chunk scheduling, so expect a brief resync. -Set it while stopped where you can. - -**Three of the others are easy to misread, so:** - -- **`vol` is the *group* volume**, not this box's output level. It goes out as a - `controller@v1` command and the server spreads it across every player in the - group, clamping per player. That is why `status` prints `group volume` and - `player volume` as two named lines rather than one ambiguous `volume:` — a - squeezelite refugee will expect `vol 50` to move *this* box, and it does not. - `delay` above is the counter-example: that one really is this endpoint's own. -- **`switch` is not a source selector.** Per the spec's switch cycle it re-homes - this client through the groups available to it. It sits next to `play` and - `pause` and means something quite different. -- **`seek-rel` takes a signed offset** and is bounded only by `int32_t`. `seek` - is absolute, non-negative, and refused past the `seek_max_ms` the server - publishes — which is absent for a live stream, where nothing bounds it. - -**Where the socket is.** `$XDG_RUNTIME_DIR/sendspin-cli-.sock`, mode `0600`, -where `` is `--port` — or, where that variable is unset and the platform has a -private directory of its own, there instead (see below). The port is in the name so -two players on one host each get their own — and it means a player on a non-default -`--port` has its socket somewhere else, so **a subcommand needs the same `--port`**, -or an explicit `--control-socket`: - -```bash -sendspin-cli --port 9000 & # this player's socket carries 9000 -sendspin-cli status --port 9000 # ...so its subcommands need it too -sendspin-cli status --control-socket /run/user/1000/sendspin-cli-9000.sock # or name it -``` - -**On macOS the default still works, because launchd sets no `$XDG_RUNTIME_DIR`.** -There the path comes from `confstr(_CS_DARWIN_USER_TEMP_DIR)` — the per-user -directory under `/var/folders` that launchd already gives every session: - -```console -$ sendspin-cli -n living-room & -I control: Listening on /var/folders/y5/9jvkfq…/T/sendspin-cli-8928.sock -$ sendspin-cli status -name: living-room -… -``` - -That is deliberately **not** `$TMPDIR`, which usually names the same directory: -`confstr()` reads nothing from the environment, so unlike `$TMPDIR` it cannot be -pointed at a directory someone else can write. And it is verified rather than -trusted — it must be a directory, owned by this user, with no group- or other-write -bit — so a platform that answered with something unsafe gets refused, not used. - -**There is no `/tmp` fallback anywhere, and that is the point.** `$XDG_RUNTIME_DIR` -and the macOS directory are both per-user and `0700`, which is what makes the socket -unreachable by other local accounts; `/tmp` is world-writable, and a socket there -would let any local user pause your music and `switch` this endpoint out of its -group. Linux enforces socket permissions on `connect()`, but macOS and the BSDs -historically do not — so the private parent directory is doing real work, not just -belt-and-braces. `$XDG_RUNTIME_DIR` wins wherever it is set, on every platform. - -With neither available — a **systemd *system* unit** on Linux, which gets no -`$XDG_RUNTIME_DIR` (a user unit does) — there is no control socket. That is **not -fatal**: a player without a control channel is still a player, exactly as one -without an mDNS advertisement is, and it warns once, naming the fix: - -```console -$ env -u XDG_RUNTIME_DIR sendspin-cli # on Linux -W control: No control socket: $XDG_RUNTIME_DIR is not set, so there is no user-private -directory to put a control socket in. Give --control-socket to choose one, or ---no-control to stop asking -I cli: sendspin-cli 0.1.0 listening on port 8928 as "living-room" (output: default, ...) -``` - -For a system unit, pair systemd's own `RuntimeDirectory=` with `--control-socket`; -`--no-control` turns the channel off and silences the warning if the player is only -ever driven by its server. - -Two caveats on the macOS path. The OS prunes `/var/folders` on a schedule, so a -very long-lived player could in principle have its socket unlinked from under it, -which looks like subcommands reporting no daemon until it restarts. -`$XDG_RUNTIME_DIR` on Linux is tmpfs cleared at logout, so it is the same class of -impermanence. And the directory is **per-user**, which is the point — so a player -run as a launchd *system* daemon puts its socket in `root`'s, where your own -`sendspin-cli status` will not find it. Both want an explicit `--control-socket`, -exactly as a systemd system unit does. - -**A second instance is refused**, in the same words `-P` uses, and refused before -it opens the sound card or its port: - -```console -$ sendspin-cli --control-socket /run/user/1000/s.sock -E control: another sendspin-cli is already running -- it holds the lock on -/run/user/1000/s.sock.lock -``` - -That comes from an exclusive `flock()` on a sibling `.lock`, held for the -process's life, with the socket unlinked and rebound underneath it — the same -`lock_file()` helper `-P` uses, which is what makes the two refusals identically -worded rather than coincidentally so. The lock is what makes "stale" and "in use" -different answers: a player killed with `SIGKILL` has its descriptor closed by the -kernel, so its leftover socket file has no lock and is simply taken over on the -next start — no cleanup step. `unlink()`-then-`bind()` on its own would race a -*live* player's socket away, and connecting to probe is a TOCTOU. (The lock file -itself is left behind; it holds nothing, and removing it would reintroduce a race.) - -Under `-z` the refusal still reaches the **terminal**, not just the log. The socket -has to be bound after the fork — it is one of the resources that invariant exists -for — so the parent probes the lock first and exits `1` at the shell, exactly as a -locked `-P` does. The child's own acquire is still the authoritative one. - -**Exit status is the interface for scripts.** The three ways a command can fail to -land are three different statuses, because they need three different actions: - -| Status | Means | -|---|---| -| `0` | sent, or answered locally (`status`, `delay`) | -| `1` | the command line did not parse (`vol 500`, `delay 5001`) | -| `2` | the player refused the argument (a `seek` past `seek_max_ms`) | -| `3` | nothing is listening on that socket — no player, or the wrong `--port` | -| `4` | the player is up but has **no server connection** | -| `5` | the server does not offer that command (`supported_commands`) | -| `6` | the exchange broke down | - -`4` and `5` are kept apart on purpose. A dropped connection *empties* -`supported_commands`, so the naive check answers "pause is not supported" when the -truth is that nothing is connected — sending you to read your server's -capabilities instead of its connection. The two locally answered subcommands — -`status` and `delay` — are never refused by any of them: nothing about either is -sent, so a missing connection is no obstacle. A disconnected player is exactly when -reading `status` is worth doing, and a speaker's own delay does not become unsettable -because nothing is playing through it. - -**No thread, and one tick of latency.** The socket is polled from the main loop -alongside mDNS, so a request round-trips in up to `LOOP_INTERVAL_MS` (10 ms). That -is the trade, and it is the right way round: `send_command()` reaches -`ConnectionManager::current()`, which is documented main-thread-only, and reading -the controller state hands back a reference to a vector the main loop -move-assigns from inside `client.loop()`. A reader thread would be a data race in -both directions. - -**The wire format**, if you want to drive it without this binary: connect, send -one line (`vol 50\n`), read until the player closes. The first line back is `ok` -or `error : `; a `status` payload follows the `ok`. One command per -connection. - -```console -$ printf 'status\n' | socat - UNIX-CONNECT:/run/user/1000/sendspin-cli-8928.sock -ok -name: living-room -... -``` - -### Stream hooks - -`--hook-start` and `--hook-stop` run a shell command when a stream starts and when -it stops — the amplifier relay, the light, the notification. The command goes -through `/bin/sh -c`, so pipes and `&&` work, and the event's facts arrive in the -environment rather than as arguments: - -| Variable | Carries | -|---|---| -| `SENDSPIN_EVENT` | `start` or `stop` | -| `SENDSPIN_SERVER_ID` | the connected server's id | -| `SENDSPIN_SERVER_NAME` | its friendly name | -| `SENDSPIN_SERVER_URL` | the URL this run dialled — set only on an `-s` run | -| `SENDSPIN_CLIENT_ID` | this player's id, when `--id` chose one | -| `SENDSPIN_CLIENT_NAME` | this player's friendly name (`-n`) | - -The vocabulary is the Python `sendspin-cli`'s, deliberately: a hook script written -against one player runs unchanged against the other. `SENDSPIN_CLIENT_ID` carries the id -`--id` or the `id` config key chose: the library derives one from the interface -MAC when neither did, and does not expose what it derived, so a run that -configured no id leaves the variable unset. A variable whose value is -unknown for the event is left unset rather than exported empty, so `[ -n -"$SENDSPIN_SERVER_ID" ]` means what it says — and any `SENDSPIN_*` inherited from -the player's own environment is cleared first, so a wrapper script's stale export -cannot describe some other run to the hook. - -`SENDSPIN_SERVER_URL` says what this run dialled, not which server answered. -The player rules out what it can: losing a connection clears the URL rather -than letting it describe whatever connects next, and when discovery chose the -server, the URL is exported only if the stream arrived from the server_id it -dialled. What it cannot rule out is a literal `-s URL` run — `-s` leaves the -inbound listener up, and a server that dials *in* while that attempt is -outstanding or has failed is a connection the player cannot tell apart from -its own: the library reports that one is up, not where it came from. A hook -that must be certain which server it is acting on should read -`SENDSPIN_SERVER_ID`, which always describes the connection the stream arrived -on. - -A stop event carries the same server facts as the start it pairs with, so -`--hook-stop 'curl -X POST .../$SENDSPIN_SERVER_ID/off'` names the server the -stream was actually on. They are the values gathered when the stream started -rather than whatever is left to ask at the end: a stream usually ends *because* -its connection went, and the server is no longer there to describe itself. - -The hook fires on the stream lifecycle, not on the format being accepted: a stream -the device refused is still audio arriving, so the amplifier is on for exactly as -long as `status` says `stream: receiving`. Nothing waits on it — a hook that blocks -cannot stall the audio path. It is reaped from the main loop; its output lands in -the log (stdout deliberately re-pointed at stderr, since `-o stdout` may be -carrying PCM); and a non-zero exit is a `W hook:` line, not a player failure. - -Hooks run one at a time, in event order, so a start hook that runs long cannot -finish after its own stream's stop hook and leave the amplifier on. While one -runs, the newest event waits — and only the newest: a stop superseded by a start -while an earlier hook is still running is skipped entirely (a `D hook:` line -records it), because the hardware should end in the final state, not replay a -stale one. A hook that *counts* events rather than setting state will see such -flapping coalesced away. However a hook misbehaves, at most one runs and one -waits — shutdown, below, is the one exception. - -A hook still running at shutdown is left to finish: an amplifier half-switched is -worse than an orphan. A player stopped while a stream is playing runs its stop -hook on the way out — beside a start hook that has still not finished, if it comes -to that (a `W hook:` line says so) — so `systemctl stop` leaves the amplifier off -rather than on. - -The hook is handed nothing of the player's but that output stream: every other -descriptor is closed and SIGPIPE is back at its default, so `something | head -1` -behaves the way it would in any other shell and a slow hook cannot sit on the -port a restart needs. - -```bash -sendspin-cli -o hw:1,0 \ - --hook-start 'amixer -c 1 set Master unmute' \ - --hook-stop 'amixer -c 1 set Master mute' -``` - -### Logging - -Every *log* line carries a level letter and a subsystem tag: - -``` -I cli: sendspin-cli 0.1.0 listening on port 8928 as "living-room" (output: default, mDNS: dns_sd (Bonjour)) -I mdns: advertising _sendspin._tcp as "living-room" on port 8928 (path /sendspin) -I sendspin.ws_server: Starting server on port: 8928 (max connections: 4) -``` - -The third line is the library's. That is the point of the format: it is the shape -sendspin-cpp's own `SS_LOG*` macros already emit, so `grep 'I mdns:'` and -`grep 'I sendspin.ws_server:'` both work on the same file. Ours are `cli`, `audio`, -`mdns`, `discovery`, `outbound`, `player`, `metadata` and `control`; the library's -are all `sendspin.`. `audio` lines then name their own backend, since the tag -says which subsystem but not which device is talking: `I audio: alsa: 'hw:1,0' -closed`. - -Fatal startup errors are in this format too, at `E`, but they are deliberately -**not** gated by `-d` — see the `-z` example above. The one line that explains why a -daemon never came up has to be both greppable and impossible to switch off. Two -kinds of diagnostic are the exception and stay plain `error: …` lines: the flag -parser's, and the pre-fork pidfile probe's. Both answer a command line rather than -recording a run, and both are printed before there is a log to write them to — -which is why a `-P` conflict reads `error: …` under `-z` and `E cli: …` in the -foreground. - -`-d` sets one level for this player and the library together, which is deliberate — -a single flag turns up everything about one run. It accepts squeezelite's -`-d =` shape, but **the category is ignored** and says so: the -library gates its lines on one global integer with no sink or filter hook, so there -is no honest way to raise the level for one category only. The per-line tag plus -`grep` is the filtering that does work, and it works on the library's lines too. - -`-f ` sends the log to a file instead of stderr, appending, and stamps every -line with a UTC timestamp: - -``` -2026-08-10T21:37:18Z I cli: sendspin-cli 0.1.0 listening on port 18931 as "living-room" (output: null, mDNS: dns_sd (Bonjour)) -``` - -Only a `-f` file is stamped. A foreground run under systemd or Docker already gets -a timestamp from journald or the container runtime, and a second one would only be -noise. The library's own lines are *not* stamped, for the same reason its category -cannot be filtered — nothing in this process gets to reformat them. - -**Rotation is `logrotate`'s and `newsyslog`'s job, not this daemon's.** `SIGHUP` -reopens the `-f` path, which is the whole handshake those tools need: - -``` -/var/log/sendspin-cli.log { - daily - rotate 7 - compress - postrotate - kill -HUP $(cat /run/sendspin-cli.pid) - endscript -} -``` - -The reopen happens on the main loop rather than in the signal handler, because it -flushes the old stream and then logs the result, and neither of those is -async-signal-safe. The handler is installed **only** with `-f`: -without it, `SIGHUP` keeps its default disposition and terminates the process, -which is what a foreground run whose terminal has just closed should do. - -### Flags, and what they refuse - -The flags follow squeezelite's: `-o` output device, `-l` list devices, `-n` name, -`-s` server, `-z` daemonize, `-P` pidfile, `-d`/`-f` logging. All but `-l` and -`-z` also have a long spelling — `--output --name --server --pidfile --logfile ---log-level` — so that every config key is a flag name. Fifteen more are long-only -because they are not squeezelite's: `--port`, the port this player serves on, -`--buffer-ms`, `--static-delay`, `--audio-format`, the two mDNS flags `--no-mdns` -and `--mdns-name`, the two control-socket flags `--control-socket` and -`--no-control`, the two stream hooks `--hook-start` and `--hook-stop`, the three -identity flags `--id`, `--manufacturer` and `--product-name`, and `--config` and -`--state-dir` for the two files above. `--id` is the one to know about: it is the *stable* id a server -files this player's volume, group and pairing under, and without it the id is -derived from the network interface MAC — so two players on one host share it, and -each server-side setting lands on whichever connected last. A dual-mono pair needs -its own `--id` per instance, and its own `--port` and `--state-dir` with it — the -state file is namespaced by neither `--id` nor `--port`, so without `--state-dir` -the two overwrite each other's volume, mute, delay and last server. Run `--help` for the current -state of each — a few still point at [`docs/ROADMAP.md`](docs/ROADMAP.md) for -behaviour that is not built yet. - -A **subcommand comes first**, before any flag: `sendspin-cli vol 50 --port 9000`, -not `sendspin-cli --port 9000 vol 50`. That is not getopt permutation showing -through — argv[1] is split off *before* `getopt_long()` runs, because getopt's -handling of a positional argument differs between glibc and the BSDs, and because -`seek-rel -5000` is indistinguishable from a flag cluster to it. A subcommand in -the wrong place says so rather than being called junk. - -Everything the flags can settle is validated before anything is opened, and a bad -value exits `1` with a single line naming it rather than falling back to a default: - -```console -$ sendspin-cli -s music.local:abc -error: -s 'music.local:abc': 'abc' is not a port number (expected 1-65535) - -$ sendspin-cli --buffer-ms 0 -error: invalid --buffer-ms '0' -- expected 10-2000 - -$ sendspin-cli --static-delay 5001 -error: invalid --static-delay '5001' -- expected 0-5000 -``` - -**`--static-delay ` is a first-run default, not an override.** It seeds the same -0–5000 static delay the `delay` subcommand sets — the latency this endpoint's hardware -adds *after* the audio port, which the player compensates for by handing audio over -earlier; see the control-channel section above for the direction, which is the opposite -of what the name suggests. The library prefers whatever the state store -remembers and reads this flag only when there is nothing remembered — exactly as a -restored volume beats the sink's default. So it is for declaring a speaker's physical -offset up front (an Ansible-managed fleet, a container with an ephemeral -`--state-dir`); once a server or `sendspin-cli delay` has set one, the remembered value -wins every run after and this flag is inert. The startup log says which of the two it -took. - -What a flag *cannot* settle on its own is whether the thing it names will open. That -is a startup failure rather than a bad value, so it comes out in the log's format — -which is what puts it in the logfile under `-z`, where it is the only record of why -the daemon never came up: - -```console -$ sendspin-cli -o portaudio:99 -E audio: -o portaudio:99: no device at that index -- indices run 0-2 here, and -l lists the ones -o can reach -``` - -That is a deliberate change from warn-and-continue. `-s` used to warn about a -malformed port and dial the default anyway; a player quietly talking to the wrong -endpoint is harder to diagnose than one that refuses to start. `-s` takes -`[:]` — filling in `8927`, the port a Sendspin *server* listens on — -or a full `ws://`/`wss://` URL, or `mdns:[]` to discover one. An IPv6 literal -must be bracketed (`[::1]:8927`), since an unbracketed one cannot be told from a -host with a port. - -### The config file, and what the player remembers - -Two files, and the split is deliberate: one you write and the daemon only ever -reads, one the daemon writes and you never need to touch. - -**The config file** holds anything you would otherwise type. Keys are the long -flag names without their dashes, one per line, and a value is exactly the string -the flag would have been given: - -``` -# /etc/sendspin-cli.conf, or ~/.config/sendspin-cli/config -name = kitchen -output = hw:1,0 -buffer-ms = 250 -static-delay = 40 -server = mdns:Living Room -no-mdns = true -``` - -That is why `-o -n -s -P -f -d` grew the long aliases `--output --name --server ---pidfile --logfile --log-level`: one vocabulary, so `--help` is the config -reference rather than a second document to keep in step. `#` starts a comment -only at the **start** of a line — a name or a path is free to contain one, and a -reader that ate everything after a `#` would silently truncate it. Booleans take -`true`/`yes`/`on`/`1` or `false`/`no`/`off`/`0`. Where a key appears twice, the -last one wins. - -The first of these that exists is read **whole**, and nothing below it is merged -over the top: - -1. `--config ` — and this one is **fatal if it cannot be read**, because - you named it. Falling back would start a player on options nobody chose. -2. `$XDG_CONFIG_HOME/sendspin-cli/config` -3. `$HOME/.config/sendspin-cli/config` -4. `/etc/sendspin-cli.conf` - -Finding none is silent and normal. There is deliberately no `--no-config`: the -asymmetry above already gives you `--config /dev/null`. A half-overridden config -assembled from several layers is far harder to reason about than one file you can -read top to bottom, which is also why there is no `$XDG_CONFIG_DIRS` traversal. - -**Precedence is command line > config file > built-in default**, per option -rather than per file — `-n bathroom` on a line whose config also sets -`buffer-ms` overrides only the name. Everything can come from a file except `-l`, -`-z`, `--config` itself, and `--help`/`--version`, which have no meaning in one. -Run shape stays on the command line: excluding it is -reversible, and debugging a `daemonize` that came out of a file under systemd is -not. A config that names one is refused as an unknown key. - -A configured value is validated by exactly the code that validates a typed one, -with the same message and the line to go and fix: - -```console -$ sendspin-cli -error: /etc/sendspin-cli.conf:4: invalid --buffer-ms '5' -- expected 10-2000 -``` - -An unknown key or a line that is not `key = value` is refused the same way. A -silently ignored typo is the failure mode this whole surface exists to avoid — so -is a config that is quietly skipped, which is why a file that exists and does not -parse stops the run rather than falling through to `/etc`. `--help`, `--version` -and `-l` short-circuit above all of it: a broken config must not stop `--help` -from telling you how to fix it. Startup logs one line naming the file in use, or -saying there is none. - -`server` from a config file behaves exactly as `-s` does, and that includes -suppressing the mDNS advertisement — the spec forbids advertising -`_sendspin._tcp` while this end is the one dialling out. - -**The state file** is the other half: what the daemon remembers for itself, -across restarts. - -``` -# Written by sendspin-cli. Edits are overwritten. -last-server = 7f3a… -last-server-hash = 3387423128 -static-delay-ms = 375 -volume = 42 -muted = true -``` - -`static-delay-ms` is there because the spec requires it — *"Clients must persist -`static_delay_ms` locally across reboots and server reconnections"* — and -`volume`/`muted` because the spec marks those RECOMMENDED. `last-server` is the -server id mDNS discovery uses to break a tie between candidates; -`last-server-hash` is the opaque `uint32_t` the library asks us to keep so *it* can -prefer the last-played server among inbound connections. They mean different things -and are deliberately not reconciled with each other. - -**A remembered `static-delay-ms` is reported *and* applied.** The player hands the -figure back to the server in its first `client/state`, so the two agree across a -restart, and the library's sync task subtracts it from every chunk's timestamp before -scheduling — which is the spec's own rule for how a client obeys it. `sendspin-cli -status` prints the value in force, and `sendspin-cli delay ` changes it. Three -things can set it: a server's `set_static_delay`, that subcommand, and -`--static-delay` on a first run with nothing yet remembered. - -It lives at `$XDG_STATE_HOME/sendspin-cli/state`, then -`$HOME/.local/state/sendspin-cli/state`, and `--state-dir

` overrides both — -a systemd **system** unit has neither variable and gets `/var/lib/sendspin-cli` -from `StateDirectory=`. With none of the three the player still runs and simply -remembers nothing. Writes go through a temporary, an `fsync` and a `rename` at -mode `0600`, so a player that loses power mid-write leaves either the old file or -the new one and never half of either. - -Two players on one host share this file unless you give each its own -`--state-dir`. They already need different `--id`s and `--port`s; give them -different state directories too, or the second one to save its volume overwrites -the first's. - -## Tests - -```bash -cmake -B build -cmake --build build -ctest --test-dir build --output-on-failure -``` - -GoogleTest is fetched at configure time and pinned to a tag. The suite is built by -default only when this is the top-level project, so vendoring `sendspin-cli` into -another build does not pay for it; `-DSENDSPIN_CLI_BUILD_TESTS=OFF` turns it off -outright. - -The suite is pure — nothing in `tests/` opens an audio device, a socket or the -mDNS daemon, which is what keeps `ctest` runnable anywhere at all. What that -leaves out is everything needing a real process, and that is a script instead: - -```bash -scripts/smoke_test.sh build/sendspin-cli -``` - -It checks that the binary runs, comes up on its port, forks under `-z`, refuses a -second instance holding the same `-P`, survives an mDNS daemon it cannot reach, -and exits `0` on `SIGTERM` — plus the whole of the control socket, which needs two -processes by definition: that it appears at the default path as `0600`, that -`status` round-trips, that `delay` reaches the player role and survives a restart -through the state store, that `--no-control` binds nothing, that a stale socket is -taken over after a `SIGKILL`, that it is gone after `SIGTERM`, and that a second -instance on the same socket is refused. The config file is in here for the same -reason: a player configured entirely from a file has to be findable by a -subcommand that repeats none of it, and that is two processes agreeing on one -path. CI runs it on every platform leg; run it yourself against any build. - -## CI - -Every branch push and pull request builds on `ubuntu-24.04`, `ubuntu-24.04-arm` -and `macos-14`, a fourth leg cross-compiled for ARMv7 on `ubuntu-24.04`, and a -fifth configured `-DSENDSPIN_CLI_WITH_MDNS=OFF` — which compiles -`src/mdns_null.cpp` in place of `src/mdns_dnssd.cpp`, so that configuration is -built rather than assumed. Every leg builds with `-DSENDSPIN_CLI_WERROR=ON` and -runs the unit suite, and each asserts from its own configure output that it found -the backends it expects: a missing `-dev` package does not fail a configure, so -without that check the matrix would happily go green on a deaf, undiscoverable -binary. - -The ARMv7 leg is cross-compiled because no runner can build it natively: GitHub -has no armv7 runner, and its arm64 runners cannot execute 32-bit ARM at all. -`scripts/build_arm32.sh` owns the cross configure, the suite and the smoke test run -under `qemu-user`, and the linked binary's own ELF build attributes are asserted to -say ARMv7, hard-float EABI before an archive is made — which is what catches a -dependency quietly compiled for something else. - -The ARMv6 build cannot be that, and the difference is the toolchain rather than the -runner: Debian and Ubuntu `armhf` are an ARMv7-A port, so a cross toolchain's own -`crt1.o` and `libgcc.a` are ARMv7 and end up in the binary whatever `-march` said. -Raspbian's are genuinely ARMv6, so it runs an ordinary native build inside a -digest-pinned Raspbian container under `qemu-user` — with its suite and smoke test -under `QEMU_CPU=arm1176`, so the emulator is no more permissive than an ARM1176 — -and asserts ARMv6, hard-float EABI off the finished binary the same way. It holds -the warning line with one exemption: Raspbian's gcc 12 has the same `-Wrestrict` -false positive the `pipewire-minimum` job documents, so it passes -`-Wno-error=restrict` and leaves `-Werror` standing over everything else. - -Emulating every compile is what that costs, and it measures 23 minutes against the -two or three every other leg takes — so ARMv6 is not in the matrix. It has a -workflow of its own, `.github/workflows/build-armv6.yml`, which runs on a push to -`main`, on a pull request touching that workflow, `scripts/build_armv6_container.sh` -or `CMakeLists.txt`, on a manual dispatch, and on a tag. Not on every branch push, -which is the whole point: a push no longer waits half an hour on it. - -The five-leg matrix lives in `.github/workflows/build.yml`, and both `ci.yml` and -`release.yml` call it. `ci.yml` ignores tags — otherwise a tag would build twice. - -A tag therefore builds one thing a push does not, which is the trade this split -buys and worth stating rather than leaving to be discovered. What limits it is that -`release.yml` calls the ARMv6 workflow beside the matrix and blocks the release on -both, so an ARMv6 break can delay a tag but cannot publish a release the archive is -missing from — and the tip of `main` gets an ARMv6 build within half an hour of a -merge either way. A pull request that expects to break ARMv6 alone, which in -practice means the `-Wrestrict` and `-latomic` classes rather than anything in the -workflow files, can be opted in with a manual dispatch. - -To try a commit without building it, open its run under the repository's Actions -tab and take `sendspin-cli---` from the run summary. Inside is a -tarball staged by the same [`install()` rules](#install) — `DESTDIR` and the -`sendspin-cli` component — so every path under its `usr/` is the path the file -installs to, plus a `BUILD-INFO.txt` at the root naming the runtime packages it needs: - -```bash -sudo tar -xzf sendspin-cli-0.1.0-linux-x86_64.tar.gz --strip-components=1 -C / \ - sendspin-cli-0.1.0-linux-x86_64/usr -sudo systemctl daemon-reload -``` - -Naming the `usr` member is what leaves `BUILD-INFO.txt` in the archive rather than -unpacking it at `/`. Or run it where you unpacked it, at -`.//usr/local/bin/sendspin-cli`. The prefix is baked in, so a binary moved out -of `/usr/local` leaves the unit naming a path with nothing at it. The macOS leg -publishes a second artifact beside that tarball, -`sendspin-cli--macos-arm64-installer`, holding the -[`.pkg`](#the-macos-installer-pkg) described below. - -Five of the six archives are built on every commit that way. The sixth, -`linux-armv6`, is on its own workflow's runs instead — a push to `main`, a pull -request touching one of the three paths above, a manual dispatch, or a tag — so on -an arbitrary branch commit there is no ARMv6 tarball to take. Dispatch the ARMv6 -workflow against that branch if you need one. - -All of these are kept for 14 days. For something that does not expire, take a -[release](../../releases) instead, which carries all six. - -## Releases - -Pushing a `vMAJOR.MINOR.PATCH` tag builds the same matrix, and the ARMv6 workflow -beside it, and publishes the five platform archives and the macOS installer `.pkg`, -plus a `SHA256SUMS` covering all six, as a GitHub Release. Both builds have to go -green: the release job needs them both, so a red ARMv6 build fails the release -rather than publishing without its archive. The workflow triggers on `v*` but -refuses anything else that matches — a prerelease like `v0.2.0-rc1` is rejected -rather than quietly published as the latest release, until somebody decides what it -should mean. -Nothing else publishes, and the workflow never creates a tag: a release exists -because a human tagged a commit -whose version `CMakeLists.txt` already agreed with. It is attached whole or not at -all — the release is drafted, its assets are checked against the set the tag is -supposed to carry, and only then is it published, so a half-finished upload leaves a -draft rather than a release missing an architecture. - -The body of a release is assembled from two halves. The half that changes is -`docs/release-notes/.md` — `docs/release-notes/0.1.6.md` for `v0.1.6`, keyed -off the same `CMakeLists.txt` version the tag is checked against — and it is written in -the pull request that bumps that version, so what a release says about itself is -reviewed alongside the change it describes. The half that does not is a short tail in -`.github/workflows/release.yml`: what `sendspin-cli --version` prints, and links to the -wiki, the README and the roadmap. Installing and verifying are not restated per release; -they live in the [wiki](https://github.com/Sendspin/sendspin-cpp-cli/wiki) and below. - -Write the notes file before pushing the tag. `preflight` refuses a tag whose file is -missing, empty, or leaves a ``` fence open, and refuses one whose sections start at `#` -or `##` — the release page supplies its own title, so those render as banner text -underneath it. Sections start at `###`; see `docs/release-notes/0.1.6.md` for the shape. `@VERSION@`, `@REPO@` and -`@TAG@` are substituted in both halves, so a compare link is written as -`https://github.com/@REPO@/compare/v0.1.5...@TAG@`. - -The archives are the same staged payload described above, so they install the same -way, and the `.pkg` wraps the macOS one. Verify whichever you took first: - -```bash -sha256sum --ignore-missing -c SHA256SUMS # Linux -shasum -a 256 --ignore-missing -c SHA256SUMS # macOS -``` - -`--ignore-missing` because `SHA256SUMS` lists all six and you have almost -certainly taken one; without it the rest are reported as failures and the command -exits non-zero on a file that is fine. Those checksums cover the six things built -here, not the `Source code` archives GitHub attaches on its own. Neither the macOS -binary nor the `.pkg` around it is signed — see below. A Developer ID signature and -notarization are still owed, tracked in [`docs/ROADMAP.md`](docs/ROADMAP.md) item 10. - -### macOS, and Gatekeeper - -Unpack the tarball from a terminal rather than in Finder: - -```bash -tar -xzf sendspin-cli-0.1.0-macos-arm64.tar.gz -./sendspin-cli-0.1.0-macos-arm64/usr/local/bin/sendspin-cli --version -``` - -That is not fussiness. These binaries are **ad-hoc signed** — the minimum an -arm64 Mach-O needs to execute at all, applied by the linker — so they carry no -developer identity and `spctl` rejects them. What decides whether you notice is -the quarantine flag, and `tar` does not propagate it where Finder's Archive -Utility does. If you did unpack in Finder, or macOS refuses it anyway: - -```bash -xattr -d com.apple.quarantine ./sendspin-cli-0.1.0-macos-arm64/usr/local/bin/sendspin-cli -``` - -### The macOS installer `.pkg` - -Every [release](#releases) attaches an installer, and the macOS CI leg publishes the -same thing per commit in its `-installer` artifact: - -```bash -sudo installer -pkg sendspin-cli-0.1.0-macos-arm64.pkg -target / -sendspin-cli --version -``` - -It carries the same four files the tarball does, staged from the same -[`install()` rules](#install), and puts them at the `/usr/local` prefix the binary -was built for. It refuses a Mac it cannot run on: the architectures are read off the -binary with `lipo` at build time and declared in the package, so an arm64-only -installer is turned away on an Intel Mac instead of reporting success and leaving a -`Bad CPU type in executable`. Build the same `.pkg` from a payload of your own with: - -```bash -DESTDIR=/tmp/stage cmake --install build --component sendspin-cli -scripts/build_macos_pkg.sh /tmp/stage 0.1.0 sendspin-cli.pkg -``` - -**The `.pkg` is not the Gatekeeper fix.** It is unsigned and unnotarized, exactly as -the binary inside it is ad-hoc signed, and `spctl -a -t install` rejects it. What it -does change is narrower and worth stating precisely, because the four ways you can -come by this file behave differently: - -- **Taken from a release page and double-clicked.** The `.pkg` is the download, so - the browser quarantines the `.pkg` itself and Gatekeeper refuses it outright — - allow it once under **System Settings → Privacy & Security**, which offers an - *Open Anyway* for the last thing it blocked, or take the terminal path below. -- **Taken from the Actions tab and double-clicked.** An artifact arrives as a *zip*, - so it is the zip that is quarantined, and what decides whether the `.pkg` inside - inherits the flag is the same thing it is for the tarball above: `unzip` from a - terminal does not propagate it, Finder's Archive Utility does. -- **`sudo installer -pkg … -target /`** — not gated at all. The `installer` CLI makes - no Gatekeeper assessment, whatever the file is flagged with, which is why CI can - install and test its own artifact and why it is the line printed above. -- **Built locally** by the script above, or unzipped with `unzip` — never - quarantined, so it opens in Installer.app with nothing to clear. - -Files that `installer` puts on disk are not quarantined either way, so the installed -`/usr/local/bin/sendspin-cli` needs no `xattr -d` — which the tarball's does if you -unpacked it in Finder. That is convenience, not identity. - -There is no uninstaller. Four files and the receipt undo it completely — the systemd -unit and the sysusers fragment in the [Install](#install) list are Linux-only and never -in this package: - -```bash -sudo rm -f /usr/local/bin/sendspin-cli -sudo rm -rf /usr/local/share/doc/sendspin-cli -sudo pkgutil --forget io.github.chrisuthe.sendspin-cli -``` - -A Developer ID signature and notarization are still owed, and the `.pkg` is what will -carry them: `xcrun stapler` staples a ticket to a `.app`, a `.dmg` or a `.pkg` and -refuses a bare executable, so notarizing the loose binary alone would still leave an -offline Mac asking Apple. Tracked in [`docs/ROADMAP.md`](docs/ROADMAP.md) item 10. - -## Roadmap +## Need more help? -See [`docs/ROADMAP.md`](docs/ROADMAP.md) for the epic breakdown and the child -tasks that build out audio backends, discovery, daemonization, the local control -channel, a config file, Docker packaging, and more. +The [wiki](https://github.com/Sendspin/sendspin-cpp-cli/wiki) is the complete end-user +reference. It covers installation, configuration, service management, local controls, +troubleshooting, and [advanced usage](https://github.com/Sendspin/sendspin-cpp-cli/wiki/Advanced-Usage). +Run `sendspin-cli --help` for the command-line reference. -## Upstream +## How can I contribute? -- Sendspin protocol library: https://github.com/Sendspin/sendspin-cpp -- Control-scheme inspiration: https://github.com/ralph-irving/squeezelite +Contributions, bug reports, and documentation improvements are welcome. Read +[contributors.md](contributors.md) for the development setup, test commands, project +layout, CI, and release process. Wiki pages are authored in this repository under +[`docs/wiki/`](docs/wiki), so documentation changes can be reviewed in a pull request. ## License -Licensed under the [Apache License 2.0](LICENSE), matching sendspin-cpp. +[MIT](LICENSE) diff --git a/contributors.md b/contributors.md new file mode 100644 index 0000000..ec4f628 --- /dev/null +++ b/contributors.md @@ -0,0 +1,64 @@ +# Contributing to sendspin-cli + +Thanks for contributing. Please open an issue or pull request in the +[repository](https://github.com/Sendspin/sendspin-cpp-cli). For user installation, +configuration, and troubleshooting documentation, update the source files in +[`docs/wiki/`](docs/wiki); the wiki is generated from those files on pushes to `main`. + +## Development setup + +You need CMake 3.16 or later, a C++20 compiler, and network access for the first +configure. CMake fetches the pinned `sendspin-cpp` dependency and its dependencies. + +Install the audio and mDNS development packages for the backends you want to exercise: + +```bash +sudo apt install pkg-config libasound2-dev portaudio19-dev libpulse-dev libpipewire-0.3-dev libavahi-compat-libdnssd-dev +sudo dnf install pkgconf alsa-lib-devel portaudio-devel pulseaudio-libs-devel pipewire-devel avahi-compat-libdnssd-devel +brew install portaudio pkgconf +``` + +Then configure, build, and test: + +```bash +cmake -B build -DSENDSPIN_CLI_WERROR=ON +cmake --build build +ctest --test-dir build --output-on-failure +``` + +Audio backends and mDNS are optional and auto-detected. Use +`-DSENDSPIN_CLI_WITH_ALSA=OFF`, `-DSENDSPIN_CLI_WITH_PORTAUDIO=OFF`, +`-DSENDSPIN_CLI_WITH_PULSE=OFF`, `-DSENDSPIN_CLI_WITH_PIPEWIRE=OFF`, or +`-DSENDSPIN_CLI_WITH_MDNS=OFF` to test a reduced build. Use +`-DSENDSPIN_CLI_BUILD_TESTS=OFF` for a quick build without the test suite. + +## Before opening a pull request + +- Keep changes focused and add or update tests for behavior changes. +- Run the focused CTest tests, or the complete `ctest --test-dir build --output-on-failure` + suite when the change crosses components. +- Run `shellcheck scripts/*.sh` after changing a shell script. +- Update the appropriate generated-wiki source in `docs/wiki/` when user-facing + behavior changes. + +GitHub Actions builds and tests Linux, macOS, and ARM targets. The ARMv6 build is +separate because it runs under emulation; it is required for releases. + +## Project layout + +- `src/` contains the player, command-line interface, output backends, discovery, and + local control implementation. +- `tests/` contains the GoogleTest unit suite. +- `packaging/` contains the service, sysusers, and configuration-file payload. +- `scripts/` contains installer, packaging, and smoke-test scripts. +- `docs/wiki/` contains the source for the published wiki. + +## Releases + +Release tags use the exact form `vMAJOR.MINOR.PATCH` and must match the version in +`CMakeLists.txt`. Before pushing a release tag, add +`docs/release-notes/.md` using the existing release notes as a template. + +Pushing the tag runs the release workflow, builds the platform archives and macOS +installer, verifies the expected assets, and publishes the GitHub Release only when all +required builds succeed. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2cc75fe..c054ea6 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -4,9 +4,9 @@ boots a real [sendspin-cpp](https://github.com/Sendspin/sendspin-cpp) client, and each item below is a follow-up task that fills in one part of the player. -The shape to aim for is [squeezelite](https://github.com/ralph-irving/squeezelite): a -headless endpoint that advertises itself, is discovered, and is driven remotely, with a -small flag set for identity, output, discovery, logging, and daemonization. +The goal is a headless endpoint that advertises itself, is discovered, and is driven +remotely, with a small flag set for identity, output, discovery, logging, and +daemonization. ## What the scaffold already does @@ -21,7 +21,7 @@ small flag set for identity, output, discovery, logging, and daemonization. auto-detected ALSA (item 2), PortAudio (item 3), PulseAudio (item 18) and PipeWire (item 19) backends, with the device-less null/stdout sink as the fallback, so the binary still runs where there is no sound card. -- Parses the squeezelite-style flag surface: `-o -l -n -s -z -P -d -f --port --buffer-ms +- Parses the command-line flag surface: `-o -l -n -s -z -P -d -f --port --buffer-ms --no-mdns --mdns-name --help --version`, validating every value at parse time and refusing to start on a bad one (item 1). - Runs as a real daemon: `-z` forks and detaches, `-P` holds a locked pidfile that refuses a @@ -111,8 +111,8 @@ The default Linux and Docker backend. In a container this needs only - Underrun (`-EPIPE`) recovery via `snd_pcm_prepare()`, and suspend (`-ESTRPIPE`) via `snd_pcm_resume()` with a `prepare()` fallback. Anything either of those cannot clear is device loss, handled through `SinkRecovery` — see item 14. -- Device enumeration for `-l` through `snd_device_name_hint()`, and `-o ` - — squeezelite's model, with `null` / `stdout` / `-` still reserved. `-o` defaults to +- Device enumeration for `-l` through `snd_device_name_hint()`, and `-o `, + with `null` / `stdout` / `-` still reserved. `-o` defaults to `default` wherever the backend is compiled in. - Software volume: Q32 fixed-point sample scaling. The taper started as upstream's quadratic one and is now the spec's `(volume/100)^1.5` (item 7). Item 3 moved it to `src/pcm_volume.{h,cpp}` @@ -122,7 +122,7 @@ The default Linux and Docker backend. In a container this needs only **Not in this slice.** Three things were deliberately left out; each is now tracked on the item that owns it, rather than as a loose follow-up here: -- The **ALSA hardware mixer** (`snd_mixer_*`, squeezelite's `-V`) → item 15. Software +- The **ALSA hardware mixer** (`snd_mixer_*`) → item 15. Software scaling was chosen first because the usual `default` device here is PipeWire's ALSA plugin, where a hardware mixer element either does not exist or moves something other than this stream. A hardware path is worth having for `hw:` output, where it is the @@ -239,7 +239,7 @@ were unreachable by construction. periods (so the default is the 100 ms ring / 20 ms period it has always been); PortAudio makes it the ring, where the 3× `outputLatency` and 1024-frame floors still win and say at `debug` which of the two did. Validated at parse time and hard-failing, per item 1. - Deliberately **not** squeezelite's `-a`: that flag's `:

::` grammar is + Deliberately not using an ALSA-specific `:

::` buffer grammar: it is ALSA-only, and two of its four subfields are already fixed here — the format is negotiated from the stream and the access mode is pinned to interleaved. One flag with two grammars per backend is the failure `src/audio_sink.h` already refuses for `-o`, so @@ -633,7 +633,7 @@ The player could only be driven by a remote controller, and `CMakeLists.txt` pin clients, which this daemon does not do". That was wrong, and it was the thing blocking this item: `controller@v1` carries the transport verbs for the group this client is *part of*, and the `player` role has none of them — only this endpoint's own volume, mute and static delay. -So the role is now on, and the deliberate addition to the squeezelite model lands here. +So the role is now on, and local control lands here. **Shipped** in `src/control.h`, `src/control_common.cpp`, `src/control_socket.cpp`, `src/control_client.cpp`, `src/cli.{h,cpp}`, `src/main.cpp`, `src/player_listener.{h,cpp}`, @@ -648,8 +648,8 @@ So the role is now on, and the deliberate addition to the squeezelite model land a protocol command with no subcommand behind it fails the suite. - **`vol` is *group* volume**, and `status` prints `group volume` and `player volume` as two named lines rather than one `volume:`. The server spreads a group volume across the group and - clamps per player, so a squeezelite refugee's expectation that `vol 50` moves *this* box is - wrong, and one ambiguous line would leave them unable to see that. `switch` is documented for + clamps per player, so `vol 50` does not move only this box. One ambiguous line would leave + that unclear. `switch` is documented for what the spec's switch cycle does — re-home this client between groups — not as the "switch playback source" its library comment suggests. - **No thread and no command queue.** `ControlSocket::poll(now_ms)` runs from the main loop @@ -2413,7 +2413,7 @@ this, and two things commonly assumed about it are wrong — the flag checked in is the outbound discovery path, not the inbound listener. **The flag, settled.** `--interface ` (config key `interface`), long-only -like the other non-squeezelite flags. **One value has to yield both halves** — the bind +like the other long-only flags. **One value has to yield both halves** — the bind needs an *address*, `DNSServiceRegister` needs an *index* — and a name is what an operator on a Pi actually knows, while the address is the thing a DHCP lease changes underneath them. That makes this a deliberate superset of Python's address-only flag rather than a diff --git a/docs/wiki/Advanced-Usage.md b/docs/wiki/Advanced-Usage.md new file mode 100644 index 0000000..68a4768 --- /dev/null +++ b/docs/wiki/Advanced-Usage.md @@ -0,0 +1,137 @@ +# Advanced usage + +Most players need only a name and an output device. This page covers the less common +ways to run `sendspin-cli`; use `sendspin-cli --help` for the complete flag reference. + +## Connection modes + +By default, the player advertises `_sendspin._tcp` over mDNS and waits for a Sendspin +server to connect: + +```bash +sendspin-cli -n living-room +``` + +To make the player connect to a known server instead, use `-s`/`--server`. This disables +mDNS advertisement because the Sendspin protocol does not allow both modes at once: + +```bash +sendspin-cli --server music.local # the server port defaults to 8927 +sendspin-cli --server music.local:9000 +sendspin-cli --server ws://music.local:9000/sendspin +sendspin-cli --server "[2001:db8::1]:8927" # an IPv6 literal must be bracketed +sendspin-cli --server mdns: # discover any server +sendspin-cli --server "mdns:Music Assistant" # ...or one by its advertised name +``` + +An outbound connection retries until it answers, and `--mdns-name` is unused in this +mode. `--no-mdns` turns the advertisement off without switching modes. + +## Choosing an output + +List the outputs available in this build and on this host, with the rates, formats, and +channel counts each one accepts: + +```bash +sendspin-cli -l +``` + +Set the selected value with `-o`/`--output` or persist it in the +[configuration file](Configuration): + +```bash +sendspin-cli --output hw:1,0 +``` + +An argument is either a reserved name (`null`, `stdout`, `-`), a `:` +pair split on the first colon (`portaudio:2`, `pulse:`, `pipewire:`), or an +ALSA PCM name such as `hw:1,0`, `plughw:1,0`, or `default`. `plughw:` lets ALSA convert +rate and format for a device that refuses the stream as it arrives. + +`default` follows the host's normal audio configuration. Under a system service, name +a hardware device such as `hw:1,0` instead; the service does not have a logged-in +desktop audio session. + +## Logging and background operation + +Run in the foreground with verbose diagnostics while investigating a problem: + +```bash +sendspin-cli -d debug +``` + +Levels are `none`, `error`, `warn`, `info` (the default), `debug`, and `verbose`. One +level covers this player and the sendspin library together, and every line is +` : `, so filter after the fact: + +```bash +sendspin-cli -d debug 2>&1 | grep ' mdns:' +``` + +For service management on Linux, prefer the supplied +[systemd service](Running-as-a-Service). For a supervisor without a journal, `-z` +detaches the process, `-f` writes the log to a file, and `-P` holds a locked pidfile: + +```bash +sendspin-cli -z -P /run/sendspin-cli.pid -f /var/log/sendspin-cli.log +``` + +`-z` refuses `-o stdout` and warns without `-f`, which is where the log would +otherwise be lost. `SIGHUP` reopens the `-f` path, so `logrotate` can rotate it. These +three, along with `-l`, `--config`, `--help`, and `--version`, cannot come from a +config file. + +## Buffering and audio format + +`--buffer-ms` controls how much audio the output backend keeps queued, from 10 to 2000 +(default 100). Raise it if a busy host produces clicks or dropouts. + +`--static-delay <0-5000>` declares how much latency this endpoint's hardware adds +*after* the audio port, so the player hands audio over that much earlier. It is a +first-run default only: once a server or [`delay`](Controlling-the-Player) has set one, +the remembered value wins. + +`--audio-format ` pins a preferred format to the front of +the advertised list, for a DAC that is only happy in one shape: + +```bash +sendspin-cli --audio-format flac:48000:24:2 +``` + +The player refuses to start if the device cannot offer the pinned format. Run +`sendspin-cli -l` to see what it accepts. + +## Identity + +`--id` sets the stable client id a server files this player's volume, group, and +pairing under; `-n` is only the displayed name. Without it, the id is derived from the +network interface MAC, which two players on one host would share. Run two players on +one host with their own `--id`, `--port`, `--state-dir`, and control socket. + +`--manufacturer` and `--product-name` set what the player reports to servers, for a +product that embeds this player and should be listed as itself. + +## Stream hooks + +`--hook-start` and `--hook-stop` run a shell command when a stream starts or stops, +which is useful for switching an amplifier or an indicator. The event's facts arrive in +the environment as `SENDSPIN_EVENT` and, where known, `SENDSPIN_SERVER_ID`, +`SENDSPIN_SERVER_NAME`, `SENDSPIN_SERVER_URL`, `SENDSPIN_CLIENT_ID`, and +`SENDSPIN_CLIENT_NAME`. Hooks never block playback, and a non-zero exit is logged as a +warning rather than failing the player. See +[Controlling the Player](Controlling-the-Player#the-player-driving-your-hardware-stream-hooks) +for the full behavior. + +Treat hook commands as local configuration: they run with the permissions of the player +process. + +## All options + +Every option and config key is listed by: + +```bash +sendspin-cli --help +``` + +Config keys are the long flag names without their dashes; see +[Configuration](Configuration#every-key). diff --git a/docs/wiki/Configuration.md b/docs/wiki/Configuration.md index 5ba07f4..287deb7 100644 --- a/docs/wiki/Configuration.md +++ b/docs/wiki/Configuration.md @@ -135,7 +135,7 @@ shape for a unit whose stderr journald already has. See [Running as a Service](Running-as-a-Service#what-is-hardened). The full argument, including why the search does not merge layers, is in -[The config file, and what the player remembers](https://github.com/Sendspin/sendspin-cpp-cli/blob/main/README.md#the-config-file-and-what-the-player-remembers). +[Where it is looked for](Configuration#where-it-is-looked-for) above. ## The state file diff --git a/docs/wiki/Controlling-the-Player.md b/docs/wiki/Controlling-the-Player.md index 94938b4..1eed59f 100644 --- a/docs/wiki/Controlling-the-Player.md +++ b/docs/wiki/Controlling-the-Player.md @@ -1,8 +1,8 @@ # Controlling the Player -The player listens on a **Unix socket**, and the same binary is its own client. This is the -deliberate addition to the squeezelite model: `sendspin-cli pause` on the player's own host -drives it, with no server and no controller app in the loop. +The player listens on a **Unix socket**, and the same binary is its own client: +`sendspin-cli pause` on the player's own host drives it, with no server and no controller +app in the loop. ```console $ sendspin-cli status @@ -56,9 +56,9 @@ own player role. ## Three that are easy to misread **`vol` is the *group* volume, not this box's output level.** It goes to the server, which -spreads it across every player in the group and clamps it per player. A squeezelite refugee -will expect `vol 50` to move *this* box, and it does not — which is why `status` prints -`group volume` and `player volume` as two named lines rather than one ambiguous `volume:`. +spreads it across every player in the group and clamps it per player. It does not change +only this box, which is why `status` prints `group volume` and `player volume` as two named +lines rather than one ambiguous `volume:`. **`switch` is not a source selector.** Per the spec's switch cycle it re-homes this client between the groups available to it. It sits next to `play` and `pause` and means something @@ -84,7 +84,8 @@ Changing it mid-stream re-times chunk scheduling, so expect a brief resync — s stopped where you can. The long version of all three is in -[The local control channel](https://github.com/Sendspin/sendspin-cpp-cli/blob/main/README.md#the-local-control-channel). +[Every subcommand](Controlling-the-Player#every-subcommand) above, and in +`sendspin-cli --help`. ## Reading `status` diff --git a/docs/wiki/Getting-Started-on-Linux.md b/docs/wiki/Getting-Started-on-Linux.md index 70f133d..ffbd12c 100644 --- a/docs/wiki/Getting-Started-on-Linux.md +++ b/docs/wiki/Getting-Started-on-Linux.md @@ -121,7 +121,7 @@ output = hw:1,0 ``` Three forms are worth knowing, and there are more in -[Choosing an output](https://github.com/Sendspin/sendspin-cpp-cli/blob/main/README.md#choosing-an-output): +[Choosing an output](Advanced-Usage#choosing-an-output): | Value | What it means | |---|---| @@ -175,7 +175,7 @@ server = mdns:Music Assistant # or discover one by its advertised name Any `server` value turns the mDNS advertisement off. That is the spec's rule rather than a preference here, and the two modes are mutually exclusive by design — see -[The two connection modes](https://github.com/Sendspin/sendspin-cpp-cli/blob/main/README.md#the-two-connection-modes). +[Connection modes](Advanced-Usage#connection-modes). ## Next diff --git a/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md b/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md index 7b6d34a..72fa8a1 100644 --- a/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md +++ b/docs/wiki/Getting-Started-on-a-Raspberry-Pi.md @@ -156,7 +156,7 @@ nothing for a player running as you. entirely, and a fixed endpoint deserves a cable. - **Underruns on a busy Pi** show as clicks or dropouts. Raise the buffer: `buffer-ms = 250` in the config. The default is 100 ms and the range is 10–2000; see - [Buffering, and what gets advertised](https://github.com/Sendspin/sendspin-cpp-cli/blob/main/README.md#buffering-and-what-gets-advertised). + [Advanced usage](Advanced-Usage#buffering-and-stream-hooks). - **`avahi-daemon` is what provides mDNS on a Pi**, and Raspberry Pi OS ships it running. If you have turned it off, the player warns and retries rather than failing — but nothing will discover it until it is back. diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index f81fb16..5dc41a2 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -1,11 +1,8 @@ # sendspin-cli -A headless **Sendspin audio player** for Linux and macOS — what squeezelite is to -Lyrion/Logitech Media Server, `sendspin-cli` is to the -[Sendspin](https://github.com/Sendspin/spec) protocol. It advertises itself over mDNS, -waits for a Sendspin server to find it, plays what it is sent in sync with every other -player in the group, and takes its flags and its ergonomics from squeezelite so that -muscle memory carries over. +A headless **Sendspin audio player** for Linux and macOS. It advertises itself over +mDNS, waits for a Sendspin server to find it, and plays what it is sent in sync with +every other player in the group. > **Status: early scaffold.** The player works; not everything on the roadmap is built. > [`docs/ROADMAP.md`](https://github.com/Sendspin/sendspin-cpp-cli/blob/main/docs/ROADMAP.md) @@ -21,6 +18,7 @@ muscle memory carries over. | [Configuration](Configuration) | The config file, and what the player remembers by itself | | [Controlling the Player](Controlling-the-Player) | `sendspin-cli pause` and the other thirteen subcommands | | [Running as a Service](Running-as-a-Service) | The systemd unit, the account it runs as, drop-ins, and reading the log | +| [Advanced Usage](Advanced-Usage) | Connection modes, output selection, logging, buffering, and stream hooks | | [Troubleshooting](Troubleshooting) | It starts and makes no sound, and the rest | ## What it does, in one screen @@ -34,8 +32,7 @@ I mdns: advertising _sendspin._tcp as "living-room" on port 8928 (path /sendspin That is the whole of the usual setup: nothing to configure on either end. A Sendspin server discovers the advertisement and dials in. `-s ` inverts it and makes this player the one dialling, which the protocol treats as the other of two mutually exclusive -modes — see -[The two connection modes](https://github.com/Sendspin/sendspin-cpp-cli/blob/main/README.md#the-two-connection-modes). +modes — see [Connection modes](Advanced-Usage#connection-modes). Audio goes out through ALSA (the Linux default) or PortAudio (the cross-platform one, and the only way to make noise on macOS), with volume applied in software on a curve the spec @@ -68,12 +65,9 @@ Anything else builds from source. ## Where things live -- **[`README.md`](https://github.com/Sendspin/sendspin-cpp-cli/blob/main/README.md)** is - the reference, and ships inside every archive at - `usr/local/share/doc/sendspin-cli/README.md`. It explains *why* the player behaves as it - does — the two connection modes, how `-o` resolves its argument, why `vol` is the group's - volume and not this box's. These wiki pages link into it rather than restating it, so - there is one copy of each argument and it is the copy an offline tarball holder also has. +- The [wiki](Home) is the complete end-user reference. It covers installation, + configuration, local control, services, troubleshooting, and + [advanced usage](Advanced-Usage). - **[`docs/ROADMAP.md`](https://github.com/Sendspin/sendspin-cpp-cli/blob/main/docs/ROADMAP.md)** is what is built, what is not, and what was actually tested rather than reasoned about. - **`sendspin-cli --help`** is the flag reference, and the config file's reference too: diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md index d59c135..5f198f6 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -14,7 +14,7 @@ Everything published is on the carries all six archives. Per-commit builds of unreleased work are under the repository's Actions tab and expire after 14 days — five archives per commit there, `linux-armv6` being built by a workflow of its own rather than on every push. See -[CI](https://github.com/Sendspin/sendspin-cpp-cli/blob/main/README.md#ci). +[the Actions tab](https://github.com/Sendspin/sendspin-cpp-cli/actions). ## What is in the archive @@ -30,6 +30,7 @@ sendspin-cli-0.1.0-linux-arm64/ ├── lib/sysusers.d/sendspin-cli.conf # Linux only └── share/doc/sendspin-cli/ ├── README.md + ├── contributors.md ├── LICENSE └── sendspin-cli.conf.example ``` @@ -129,12 +130,8 @@ to check. Whether you notice depends entirely on the quarantine flag, which `tar xattr -d com.apple.quarantine ./sendspin-cli-0.1.0-macos-arm64/usr/local/bin/sendspin-cli ``` -The full picture — including why `sudo installer` is not gated at all, and why the `.pkg` -exists despite not fixing Gatekeeper — is in -[macOS, and Gatekeeper](https://github.com/Sendspin/sendspin-cpp-cli/blob/main/README.md#macos-and-gatekeeper) -and -[The macOS installer `.pkg`](https://github.com/Sendspin/sendspin-cpp-cli/blob/main/README.md#the-macos-installer-pkg). -A Developer ID signature and notarization are owed and tracked as roadmap item 10. +A Developer ID signature and notarization are owed and tracked as roadmap item 10, so +`sudo installer` and the tarball are both affected until that lands. There is no launchd job in the payload. On macOS the player runs from a shell or under a launch agent you write. @@ -188,8 +185,8 @@ sudo cmake --install build --component sendspin-cli `--component sendspin-cli` is not garnish — without it, `cmake --install` also stages 143 files belonging to a fetched dependency. The prefix is fixed at *configure* time because the unit's `ExecStart` names it absolutely, so reconfigure rather than passing -`--install --prefix`. Both points, at length, in -[Install](https://github.com/Sendspin/sendspin-cpp-cli/blob/main/README.md#install). +`--install --prefix`. Building from source is covered in +[contributors.md](https://github.com/Sendspin/sendspin-cpp-cli/blob/main/contributors.md). ## Next diff --git a/docs/wiki/Running-as-a-Service.md b/docs/wiki/Running-as-a-Service.md index 272ffdd..f7c7ab1 100644 --- a/docs/wiki/Running-as-a-Service.md +++ b/docs/wiki/Running-as-a-Service.md @@ -34,7 +34,7 @@ the unit runs as, out of a declaration installed beside the unit, and a tarball | `/usr/local/bin/sendspin-cli` | the binary | | `/usr/local/lib/systemd/system/sendspin-cli.service` | the unit | | `/usr/local/lib/sysusers.d/sendspin-cli.conf` | the account the unit runs as, declared | -| `/usr/local/share/doc/sendspin-cli/README.md` | the reference | +| `/usr/local/share/doc/sendspin-cli/README.md` | quick-start and wiki links | | `/usr/local/share/doc/sendspin-cli/LICENSE` | Apache 2.0 | | `/usr/local/share/doc/sendspin-cli/sendspin-cli.conf.example` | an annotated config | @@ -180,8 +180,8 @@ Read the installed unit for the full block. Three operator-visible edges: [`docs/ROADMAP.md`](https://github.com/Sendspin/sendspin-cpp-cli/blob/main/docs/ROADMAP.md) item 10. -The full argument for every directive, and the `systemd-analyze security` figures, are in -[The systemd unit](https://github.com/Sendspin/sendspin-cpp-cli/blob/main/README.md#the-systemd-unit). +Every directive the unit sets is listed above; `systemd-analyze security sendspin-cli` +reports what your own systemd version makes of it. ### Changing any of it @@ -225,7 +225,7 @@ saying why". Lines are not timestamped by the player under systemd, because journald already stamps them and a second one would be noise. Only a `-f` logfile gets our own timestamp. See -[Logging](https://github.com/Sendspin/sendspin-cpp-cli/blob/main/README.md#logging). +[Advanced usage](Advanced-Usage#logging-and-background-operation). ## A user unit instead diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md index a25061e..8630b4a 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/wiki/Troubleshooting.md @@ -155,7 +155,7 @@ and the Sendspin spec forbids advertising while it is ``` There is deliberately no flag that turns both modes on together. See -[The two connection modes](https://github.com/Sendspin/sendspin-cpp-cli/blob/main/README.md#the-two-connection-modes). +[Connection modes](Advanced-Usage#connection-modes). **`no-mdns` is set.** Remove it. @@ -301,7 +301,7 @@ buffer-ms = 250 That is one figure for every backend — ALSA divides it into periods, PortAudio makes it the ring size, and a device-less sink ignores it. A figure smaller than one device buffer is raised to the floor and says so at `debug`. See -[Buffering, and what gets advertised](https://github.com/Sendspin/sendspin-cpp-cli/blob/main/README.md#buffering-and-what-gets-advertised). +[Buffering and stream hooks](Advanced-Usage#buffering-and-stream-hooks). If it is one speaker out of sync with the others rather than dropping out, that is `delay`, not `buffer-ms` — see [Controlling the Player](Controlling-the-Player). @@ -354,7 +354,7 @@ sendspin-cli -z -f /var/log/sendspin-cli.log -P /run/sendspin-cli.pid Under systemd, neither flag belongs: `Type=simple` in the foreground puts everything in the journal. See -[Running as a daemon](https://github.com/Sendspin/sendspin-cpp-cli/blob/main/README.md#running-as-a-daemon). +[Logging and background operation](Advanced-Usage#logging-and-background-operation). ## Still stuck diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index cb821f4..83dc279 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -13,6 +13,7 @@ - [Configuration](Configuration) - [Controlling the Player](Controlling-the-Player) - [Running as a Service](Running-as-a-Service) +- [Advanced Usage](Advanced-Usage) **When it misbehaves** @@ -21,6 +22,6 @@ --- - [Repository](https://github.com/Sendspin/sendspin-cpp-cli) -- [README](https://github.com/Sendspin/sendspin-cpp-cli/blob/main/README.md) +- [Contributing](https://github.com/Sendspin/sendspin-cpp-cli/blob/main/contributors.md) - [Roadmap](https://github.com/Sendspin/sendspin-cpp-cli/blob/main/docs/ROADMAP.md) - [Releases](https://github.com/Sendspin/sendspin-cpp-cli/releases) diff --git a/src/alsa_sink.h b/src/alsa_sink.h index 4c5cc0c..a0e58d9 100644 --- a/src/alsa_sink.h +++ b/src/alsa_sink.h @@ -36,7 +36,7 @@ namespace sendspin_cli { /// /// The device string is an ALSA PCM name exactly as `aplay -L` prints it -- `default`, /// `pipewire`, `hw:2,0`, `plughw:1,0`, `hdmi:CARD=NVidia,DEV=0`. Anything `-o` does not -/// reserve for the device-less sinks is handed here, which is squeezelite's model. +/// reserve for the device-less sinks is handed here, which is the conventional model. /// /// THREAD SAFETY: an snd_pcm_t is not thread-safe, so every snd_pcm_* call is made under /// device_mutex_. write() runs on the sync task's background thread while configure(), diff --git a/src/audio_sink.cpp b/src/audio_sink.cpp index c99f137..8e8fe5a 100644 --- a/src/audio_sink.cpp +++ b/src/audio_sink.cpp @@ -288,7 +288,7 @@ bool resolve_device_spec(const std::string& spec, DeviceSpec& out, std::string& return false; } - // 3. Anything else is an ALSA PCM name, which is how squeezelite's -o behaves: there + // 3. Anything else is an ALSA PCM name, which is how a conventional `-o` behaves: there // is no fixed device list to keep in sync with the host's hardware. PortAudio is // deliberately not reachable this way -- it *does* enumerate its devices, so the // justification does not carry over, and a bare name resolving per host is exactly diff --git a/src/audio_sink.h b/src/audio_sink.h index 99e0f0c..ffc4ac2 100644 --- a/src/audio_sink.h +++ b/src/audio_sink.h @@ -228,7 +228,7 @@ struct DeviceSpec { /// 2. The string contains a colon and the text before the **first** colon names a /// backend -- first colon only, because ALSA device names carry their own, so /// `alsa:hw:2,0` is the ALSA backend playing `hw:2,0`. -/// 3. Otherwise it is an ALSA PCM name, which is squeezelite's `-o` model. `hw:2,0` and +/// 3. Otherwise it is an ALSA PCM name, the conventional `-o` model. `hw:2,0` and /// `default` keep working with no prefix at all. This stays ALSA-only on purpose: /// PortAudio *does* have an enumerable device list, so reaching one of its devices /// without the prefix would make the same `-o` mean different things per host. diff --git a/src/cli.cpp b/src/cli.cpp index 289fa01..504f878 100644 --- a/src/cli.cpp +++ b/src/cli.cpp @@ -57,7 +57,7 @@ constexpr uint16_t DEFAULT_REMOTE_SERVER_PORT = 8927U; constexpr const char* USERINFO_MASK = "***"; /// Long-only option values, picked outside the short-option alphabet so `-V`/`-p` stay -/// unclaimed for squeezelite's own meanings. +/// unclaimed for future use. enum LongOnly { OPT_VERSION = 0x100, OPT_PORT, @@ -210,7 +210,7 @@ std::string offending_option(char* const argv[], int index) { return word; } -/// Maps a level name onto the library's LogLevel. Accepts squeezelite's vocabulary +/// Maps a level name onto the library's LogLevel. Accepts the common vocabulary /// (info, debug, sdebug) as well as the library's own names. bool parse_log_level(const char* str, LogLevel& level) { if (std::strcmp(str, "none") == 0 || std::strcmp(str, "off") == 0) { @@ -231,7 +231,7 @@ bool parse_log_level(const char* str, LogLevel& level) { return true; } -/// Accepts squeezelite's `-d =` shape. +/// Accepts a `-d =` shape. /// /// The category is parsed and ignored, and stays that way: sendspin-cpp logs through /// `fprintf(stderr)` macros gated on one global int with no sink or filter hook, so raising @@ -599,7 +599,7 @@ bool parse_options(int argc, char* argv[], Options& out, std::FILE* err) { static const struct option long_opts[] = { {"help", no_argument, nullptr, 'h'}, {"version", no_argument, nullptr, OPT_VERSION}, - // Long aliases for the six squeezelite letters, routed to the same handlers. They exist so + // Long aliases for the six short letters, routed to the same handlers. They exist so // every config key is a flag name: one vocabulary, and --help stays the config reference. {"output", required_argument, nullptr, 'o'}, {"name", required_argument, nullptr, 'n'}, @@ -1108,8 +1108,8 @@ void print_usage(std::FILE* out, const char* prog) { std::fprintf(out, " -d, --log-level \n"); std::fprintf(out, " Log level: none, error, warn, info, debug, verbose\n"); std::fprintf(out, " One level for this player and the sendspin library\n"); - std::fprintf(out, " together. Accepts squeezelite's =\n"); - std::fprintf(out, " shape, but the category is ignored: every line is\n"); + std::fprintf(out, " together. Accepts a = shape, but the\n"); + std::fprintf(out, " category is ignored: every line is\n"); std::fprintf(out, " ' : ', so filter it with grep\n"); std::fprintf(out, " -f, --logfile \n"); std::fprintf(out, " Write log output to instead of stderr, with a\n"); diff --git a/src/cli.h b/src/cli.h index ef39bca..88c4c68 100644 --- a/src/cli.h +++ b/src/cli.h @@ -13,7 +13,7 @@ // limitations under the License. /// @file cli.h -/// @brief squeezelite-style command line surface for sendspin-cli +/// @brief Command line surface for sendspin-cli #pragma once @@ -125,8 +125,8 @@ enum class Opt : unsigned { /// @brief Everything the flag surface configures. /// -/// The short flags deliberately mirror squeezelite's, so anyone who runs a Lyrion -/// endpoint can drive this one from muscle memory. +/// The short flags follow the conventions of common headless players, so anyone who +/// runs one can drive this one from muscle memory. struct Options { std::string device{DEFAULT_OUTPUT_DEVICE}; ///< -o : audio output backend bool list_devices{false}; ///< -l: list output devices and exit @@ -152,17 +152,17 @@ struct Options { std::string logfile; ///< -f : send log output to this file sendspin::LogLevel log_level{sendspin::LogLevel::INFO}; ///< -d [=] - /// --port : the port our own WebSocket server listens on. Not a squeezelite - /// flag -- a sendspin player is dialled *by* the server, so the listen port is part - /// of its identity. Long-only, to leave -p free for squeezelite's priority flag. + /// --port : the port our own WebSocket server listens on. Not a conventional + /// player flag -- a sendspin player is dialled *by* the server, so the listen port is part + /// of its identity. Long-only, to leave -p free. uint16_t port{sendspin::SendspinClientConfig::DEFAULT_SERVER_PORT}; /// --buffer-ms : how much audio the output backend keeps buffered, MIN_BUFFER_MS to - /// MAX_BUFFER_MS. One figure for every backend, which is why it is not squeezelite's - /// `-a`: that flag's `:

::` grammar is ALSA-only, and two of its four + /// MAX_BUFFER_MS. One figure for every backend, which is why it is not an ALSA-specific + /// `:

::` grammar: that shape is ALSA-only, and two of its four /// subfields are already fixed here -- the format is negotiated from the stream and the /// access mode is pinned to interleaved. Long-only for the same reason as --port, so no - /// squeezelite letter is squatted. + /// short letter is squatted. /// /// A request rather than a promise: a device-less sink (`null`, `stdout`) has nothing to /// size and ignores it, and PortAudio's device-latency floor overrides a figure too diff --git a/tests/cli_test.cpp b/tests/cli_test.cpp index f58a491..5ae216f 100644 --- a/tests/cli_test.cpp +++ b/tests/cli_test.cpp @@ -412,8 +412,8 @@ TEST(ParseOptions, StaticDelayIsListedByHelpAsAFirstRunDefault) { } TEST(ParseOptions, BufferMsDoesNotClaimDashA) { - // squeezelite's -a is deliberately left unclaimed: its :

:: grammar is - // ALSA-only, and two of its four subfields are already fixed here. + // -a is deliberately left unclaimed: an ALSA-only :

:: grammar, + // and two of its four subfields are already fixed here. Parse parse({"-a", "100"}); EXPECT_FALSE(parse.ok()); @@ -432,7 +432,7 @@ TEST(ParseOptions, LogLevelNames) { {"warn", LogLevel::WARN}, {"warning", LogLevel::WARN}, {"info", LogLevel::INFO}, {"debug", LogLevel::DEBUG}, {"verbose", LogLevel::VERBOSE}, - // squeezelite's own name for the loudest level. + // The conventional name for the loudest level. {"sdebug", LogLevel::VERBOSE}, }; @@ -445,7 +445,7 @@ TEST(ParseOptions, LogLevelNames) { } TEST(ParseOptions, LogCategoryIsAcceptedAndWarnedAboutWithSomethingToDoInstead) { - // squeezelite's -d = shape. sendspin-cpp gates every line on one global + // The -d = shape. sendspin-cpp gates every line on one global // int with no sink hook, so the category is parsed, ignored, and said out loud -- and the // warning has to leave the user somewhere to go, which is the per-line tag plus grep. Parse parse({"-d", "slimproto=info"}); From d247b4f60bec6b3248de1af53e518fdf58768845 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 3 Sep 2026 11:46:46 -0500 Subject: [PATCH 2/4] Tell the payload assertions about contributors.md The new doc ships via install(), so the three hard-coded expected-file lists -- the staged payload and the macOS pkg receipt in build.yml, and the staged payload in build-armv6.yml -- all failed on a diff. Add it to each, and to the payload table in the Running as a Service page. --- .github/workflows/build-armv6.yml | 1 + .github/workflows/build.yml | 2 ++ docs/wiki/Running-as-a-Service.md | 1 + 3 files changed, 4 insertions(+) diff --git a/.github/workflows/build-armv6.yml b/.github/workflows/build-armv6.yml index ccd96fc..b43c6a6 100644 --- a/.github/workflows/build-armv6.yml +++ b/.github/workflows/build-armv6.yml @@ -477,6 +477,7 @@ jobs: echo usr/local/lib/systemd/system/sendspin-cli.service echo usr/local/share/doc/sendspin-cli/LICENSE echo usr/local/share/doc/sendspin-cli/README.md + echo usr/local/share/doc/sendspin-cli/contributors.md echo usr/local/share/doc/sendspin-cli/sendspin-cli.conf.example } | sort >"$expected" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index db1631f..be29e1e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -729,6 +729,7 @@ jobs: echo usr/local/bin/sendspin-cli echo usr/local/share/doc/sendspin-cli/LICENSE echo usr/local/share/doc/sendspin-cli/README.md + echo usr/local/share/doc/sendspin-cli/contributors.md echo usr/local/share/doc/sendspin-cli/sendspin-cli.conf.example # Installed on Linux alone: a systemd unit, and the account declaration it needs, # are both files a Mac can only ignore. @@ -1015,6 +1016,7 @@ jobs: echo bin/sendspin-cli echo share/doc/sendspin-cli/LICENSE echo share/doc/sendspin-cli/README.md + echo share/doc/sendspin-cli/contributors.md echo share/doc/sendspin-cli/sendspin-cli.conf.example } | sort >"$expected" diff --git a/docs/wiki/Running-as-a-Service.md b/docs/wiki/Running-as-a-Service.md index f7c7ab1..b268bd7 100644 --- a/docs/wiki/Running-as-a-Service.md +++ b/docs/wiki/Running-as-a-Service.md @@ -35,6 +35,7 @@ the unit runs as, out of a declaration installed beside the unit, and a tarball | `/usr/local/lib/systemd/system/sendspin-cli.service` | the unit | | `/usr/local/lib/sysusers.d/sendspin-cli.conf` | the account the unit runs as, declared | | `/usr/local/share/doc/sendspin-cli/README.md` | quick-start and wiki links | +| `/usr/local/share/doc/sendspin-cli/contributors.md` | how to build and contribute | | `/usr/local/share/doc/sendspin-cli/LICENSE` | Apache 2.0 | | `/usr/local/share/doc/sendspin-cli/sendspin-cli.conf.example` | an annotated config | From 722c52617b2b839105380122540bdeabdb460db8 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 3 Sep 2026 12:25:36 -0500 Subject: [PATCH 3/4] Put the four everyday flags in the README's usage section A reader who gets as far as starting a player next wants to name it, choose a sound card, pin a format, or point it at a server. Those were a wiki hop away. Also corrects the licence line, which said MIT over an Apache 2.0 LICENSE. --- README.md | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3922bcf..acb2e10 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,29 @@ It advertises itself on the local network and waits for a Sendspin server to con For a Linux system-service installation, the quick-start guide explains how to enable and check the service. +The options most people need beyond a name: + +```bash +# Pick a sound card -- run `sendspin-cli -l` to list what this host has +sendspin-cli -n living-room -o hw:1,0 + +# Pin a format, for a DAC that is only happy in one shape +sendspin-cli -n living-room --audio-format flac:48000:24:2 + +# Connect out to a specific server, instead of waiting to be found +sendspin-cli -n living-room -s music.local +``` + +| Option | What it does | +|---|---| +| `-n, --name ` | The friendly name a server displays. Defaults to this host's name. | +| `-o, --output ` | Which sound card to play through. `-l` lists this host's devices and what they accept. | +| `--audio-format ` | Offers this format first, e.g. `flac:48000:24:2`. Everything else the device takes is still offered behind it. | +| `-s, --server ` | Connect out to a server rather than waiting to be discovered. Turns off the mDNS advertisement. | + +Any of these can go in a config file instead of on the command line — see +[Configuration](https://github.com/Sendspin/sendspin-cpp-cli/wiki/Configuration). + Common local controls are available from the same host: ```bash @@ -72,4 +95,4 @@ layout, CI, and release process. Wiki pages are authored in this repository unde ## License -[MIT](LICENSE) +[Apache 2.0](LICENSE) From 8dd0a16b92ba3d6cd7b00fd9b52db8aea551a69c Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 3 Sep 2026 12:29:09 -0500 Subject: [PATCH 4/4] Drop the early-scaffold status banner The player is feature-complete for everyday use, so leading both the README and the wiki landing page with a caveat about missing features misrepresents it. The roadmap is still there for anyone who wants the detail. --- README.md | 3 --- docs/wiki/Home.md | 4 ---- 2 files changed, 7 deletions(-) diff --git a/README.md b/README.md index acb2e10..d1a33e1 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,6 @@ Sendspin server finds it, and it plays synchronized audio with the rest of your It runs on Linux and Apple-silicon macOS. On Linux, it can also run as a system service. -> **Status: early scaffold.** The player works, but not every planned feature is -> available yet. See the [roadmap](docs/ROADMAP.md) for the current status. - ## How can I run it? ### Linux and Raspberry Pi diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 5dc41a2..bbf7241 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -4,10 +4,6 @@ A headless **Sendspin audio player** for Linux and macOS. It advertises itself o mDNS, waits for a Sendspin server to find it, and plays what it is sent in sync with every other player in the group. -> **Status: early scaffold.** The player works; not everything on the roadmap is built. -> [`docs/ROADMAP.md`](https://github.com/Sendspin/sendspin-cpp-cli/blob/main/docs/ROADMAP.md) -> is the honest list of what is and is not done. - ## Start here | | |