Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/build-armv6.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"

Expand Down
9 changes: 5 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
1,563 changes: 57 additions & 1,506 deletions README.md

Large diffs are not rendered by default.

64 changes: 64 additions & 0 deletions contributors.md
Original file line number Diff line number Diff line change
@@ -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/<version>.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.
24 changes: 12 additions & 12 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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 <any PCM name>`
— squeezelite's model, with `null` / `stdout` / `-` still reserved. `-o` defaults to
- Device enumeration for `-l` through `snd_device_name_hint()`, and `-o <any PCM name>`,
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}`
Expand All @@ -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
Expand Down Expand Up @@ -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 `<b>:<p>:<f>:<m>` grammar is
Deliberately not using an ALSA-specific `<b>:<p>:<f>:<m>` 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
Expand Down Expand Up @@ -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}`,
Expand All @@ -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
Expand Down Expand Up @@ -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 <name-or-address>` (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
Expand Down
137 changes: 137 additions & 0 deletions docs/wiki/Advanced-Usage.md
Original file line number Diff line number Diff line change
@@ -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 `<backend>:<device>`
pair split on the first colon (`portaudio:2`, `pulse:<sink>`, `pipewire:<node>`), 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
`<L> <tag>: <message>`, 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 <codec:rate:depth:channels>` 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).
2 changes: 1 addition & 1 deletion docs/wiki/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 8 additions & 7 deletions docs/wiki/Controlling-the-Player.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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`

Expand Down
4 changes: 2 additions & 2 deletions docs/wiki/Getting-Started-on-Linux.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading