Skip to content
Draft
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 @@ -84,6 +84,7 @@ jobs:
-DCMAKE_BUILD_TYPE=Release
-DSENDSPIN_CLI_WERROR=ON
-DSENDSPIN_CLI_WITH_MDNS=ON
-DSENDSPIN_CLI_WITH_PORTAUDIO=ON
-DSENDSPIN_CLI_WITH_PULSE=ON
-DSENDSPIN_CLI_WITH_PIPEWIRE=ON
-DFETCHCONTENT_BASE_DIR="$DEPS_DIR"
Expand Down
44 changes: 38 additions & 6 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ jobs:
runner: ubuntu-24.04
arm32: ''
with_mdns: 'ON'
with_portaudio: 'ON'
with_pulse: 'ON'
with_pipewire: 'ON'
expect_probe: 'Failed'
Expand All @@ -48,6 +49,7 @@ jobs:
runner: ubuntu-24.04-arm
arm32: ''
with_mdns: 'ON'
with_portaudio: 'ON'
with_pulse: 'ON'
with_pipewire: 'ON'
expect_probe: 'Failed'
Expand All @@ -63,6 +65,7 @@ jobs:
runner: ubuntu-24.04
arm32: 'armv7'
with_mdns: 'ON'
with_portaudio: 'ON'
with_pulse: 'ON'
with_pipewire: 'ON'
expect_probe: 'Failed'
Expand All @@ -78,12 +81,13 @@ jobs:
runner: macos-14
arm32: ''
with_mdns: 'ON'
with_portaudio: 'OFF'
with_pulse: 'OFF'
with_pipewire: 'OFF'
expect_probe: 'Success'
expect_backends: '^-- sendspin-cli audio backends: null, stdout, portaudio$'
expect_backends: '^-- sendspin-cli audio backends: null, stdout, coreaudio$'
expect_mdns: '^-- sendspin-cli mDNS: dns_sd \(Bonjour, built in\)$'
runtime_packages: 'portaudio (brew). Bonjour is part of macOS.'
runtime_packages: 'none. CoreAudio, AudioToolbox and Bonjour are all part of macOS.'
publish: true
avahi: false
systemd: false
Expand All @@ -93,6 +97,7 @@ jobs:
runner: ubuntu-24.04
arm32: ''
with_mdns: 'OFF'
with_portaudio: 'ON'
with_pulse: 'OFF'
with_pipewire: 'OFF'
expect_backends: '^-- sendspin-cli audio backends: null, stdout, alsa, portaudio$'
Expand Down Expand Up @@ -200,10 +205,6 @@ jobs:
echo 'A 32-bit ARM binary runs on this host.'
rm -f binfmt-probe binfmt-probe.c

- name: Install build dependencies (macOS)
if: runner.os == 'macOS'
run: brew install portaudio pkgconf

# Fetched sources and subbuild stamps only, never build outputs; keyed per leg and by CMakeLists.txt.
- name: Cache the fetched sources
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
Expand All @@ -216,6 +217,7 @@ jobs:
- name: Configure
env:
WITH_MDNS: ${{ matrix.with_mdns }}
WITH_PORTAUDIO: ${{ matrix.with_portaudio }}
WITH_PULSE: ${{ matrix.with_pulse }}
WITH_PIPEWIRE: ${{ matrix.with_pipewire }}
DEPS_DIR: ${{ github.workspace }}/.deps
Expand All @@ -226,6 +228,7 @@ jobs:
-DCMAKE_BUILD_TYPE=Release
-DSENDSPIN_CLI_WERROR=ON
-DSENDSPIN_CLI_WITH_MDNS="$WITH_MDNS"
-DSENDSPIN_CLI_WITH_PORTAUDIO="$WITH_PORTAUDIO"
-DSENDSPIN_CLI_WITH_PULSE="$WITH_PULSE"
-DSENDSPIN_CLI_WITH_PIPEWIRE="$WITH_PIPEWIRE"
-DFETCHCONTENT_BASE_DIR="$DEPS_DIR"
Expand Down Expand Up @@ -552,6 +555,35 @@ jobs:
exit 1
}

# What would have caught the dyld abort: a Homebrew load command is a dependency no clean
# Mac has, and CI never sees it because the runner installed it.
- name: Assert the macOS binary loads nothing that is not part of macOS
if: matrix.publish && runner.os == 'macOS'
env:
NAME: ${{ steps.package.outputs.name }}
run: |
binary="stage/$NAME/usr/local/bin/sendspin-cli"
echo 'What the staged binary loads:'
otool -L "$binary"

# Each dependency is a tab-indented line whose first field is the install name; the
# header line naming the binary itself is not indented.
otool -L "$binary" | sed -n 's|^\t\([^ ]*\).*|\1|p' >load-commands.txt

status=0
while read -r path; do
case "$path" in
/usr/lib/*|/System/Library/Frameworks/*|/System/Library/PrivateFrameworks/*) ;;
*)
echo "::error::the binary loads $path, which is not part of macOS -- a Mac without it aborts at launch"
status=1
;;
esac
done <load-commands.txt

rm -f load-commands.txt
exit "$status"

# Install at the real prefix and verify the unit (not on cross legs: MemoryDenyWriteExecute= forbids qemu-user).
# After Package, or a root-owned install manifest in build/ breaks the unprivileged staging install.
- name: Install at the real prefix and verify the systemd unit
Expand Down
17 changes: 17 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ FetchContent_MakeAvailable(sendspin)
# Each backend is auto-detected; OFF forces it out.
option(SENDSPIN_CLI_WITH_ALSA "Build the ALSA output backend if libasound is available" ON)

# macOS only; the frameworks it needs are part of the system, so there is nothing to install.
option(SENDSPIN_CLI_WITH_COREAUDIO "Build the CoreAudio output backend on macOS" ON)

option(SENDSPIN_CLI_WITH_PORTAUDIO
"Build the PortAudio output backend if libportaudio is available" ON)

Expand All @@ -67,6 +70,7 @@ option(SENDSPIN_CLI_WITH_PIPEWIRE

set(SENDSPIN_CLI_BACKENDS null stdout)
set(SENDSPIN_CLI_ALSA_ENABLED OFF)
set(SENDSPIN_CLI_COREAUDIO_ENABLED OFF)
set(SENDSPIN_CLI_PORTAUDIO_ENABLED OFF)
set(SENDSPIN_CLI_PULSE_ENABLED OFF)
set(SENDSPIN_CLI_PIPEWIRE_ENABLED OFF)
Expand All @@ -86,6 +90,12 @@ if(SENDSPIN_CLI_WITH_ALSA)
endif()
endif()

if(SENDSPIN_CLI_WITH_COREAUDIO AND APPLE)
# CoreAudio and AudioToolbox ship with macOS, so there is nothing to find.
set(SENDSPIN_CLI_COREAUDIO_ENABLED ON)
list(APPEND SENDSPIN_CLI_BACKENDS coreaudio)
endif()

if(SENDSPIN_CLI_WITH_PORTAUDIO)
# No CMake module; IMPORTED_TARGET carries macOS framework links through.
find_package(PkgConfig QUIET)
Expand Down Expand Up @@ -277,6 +287,13 @@ if(SENDSPIN_CLI_ALSA_ENABLED)
target_compile_definitions(sendspin-cli-core PUBLIC SENDSPIN_CLI_HAVE_ALSA)
endif()

if(SENDSPIN_CLI_COREAUDIO_ENABLED)
target_sources(sendspin-cli-core PRIVATE src/coreaudio_sink.cpp)
target_link_libraries(sendspin-cli-core PUBLIC
"-framework CoreAudio" "-framework AudioToolbox" "-framework CoreFoundation")
target_compile_definitions(sendspin-cli-core PUBLIC SENDSPIN_CLI_HAVE_COREAUDIO)
endif()

if(SENDSPIN_CLI_PORTAUDIO_ENABLED)
target_sources(sendspin-cli-core PRIVATE src/portaudio_sink.cpp)
target_link_libraries(sendspin-cli-core PUBLIC PkgConfig::PORTAUDIO)
Expand Down
104 changes: 99 additions & 5 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ daemonization.
discovers a server on `_sendspin-server._tcp` and dials it — retrying with a backoff
until it answers (item 5). A typed-in address is no longer accepted (item 26).
- Defines the `AudioSink` seam (`src/audio_sink.h`) and plays real audio through it:
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.
auto-detected ALSA (item 2), CoreAudio (item 27), 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 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).
Expand Down Expand Up @@ -134,8 +134,15 @@ the item that owns it, rather than as a loose follow-up here:

### 3. PortAudio backend — *shipped (audible slice)*

The cross-platform backend (macOS and Linux), and the only way this player makes noise on
macOS, where there is no ALSA at all. Upstream's `examples/common/portaudio_sink.cpp` was
> **macOS moved off this backend in item 27.** The released macOS binary inherited
> Homebrew's absolute install name for `libportaudio.2.dylib` and aborted under `dyld` on
> any Mac without it — a dependency CI could never see, because the runner had brewed it.
> A native CoreAudio sink needs only frameworks every Mac already has, so PortAudio is now
> built `OFF` on the macOS release leg. Everything below still describes the Linux build,
> and a Mac configured with `-DSENDSPIN_CLI_WITH_PORTAUDIO=ON` still gets both.

The cross-platform backend (macOS and Linux), and for its first two releases the only way
this player made noise on macOS, where there is no ALSA at all. Upstream's `examples/common/portaudio_sink.cpp` was
the reference, but it is not an `AudioSink`, has no device selection, and logs with bare
`fprintf(stderr)` — so it was ported into `src/` rather than compiled out of the
FetchContent tree, whose `examples/` path is not a stable interface.
Expand Down Expand Up @@ -2485,3 +2492,90 @@ hand-entered address is neither, so it went.
credential-redaction check went with them.
- **`SENDSPIN_SERVER_URL` is answered only for the server_id the dial chose.** `LastDial` has
no "literal URL, taken at its word" case left; a dial with no id answers nothing.

### 27. Native CoreAudio backend — *shipped (hardware pass still owed)*

The shipped macOS binary aborted at launch on any Mac without Homebrew's PortAudio:

```
dyld[38468]: Library not loaded: /opt/homebrew/opt/portaudio/lib/libportaudio.2.dylib
Referenced from: /usr/local/bin/sendspin-cli
```

CI brewed `portaudio` on the `macos-14` runner and then smoke-tested the `.pkg` on that same
runner, so the one host that could have caught it was the one host guaranteed not to. The fix
is not to bundle the dylib but to stop needing it: CoreAudio and AudioToolbox are system
frameworks, so the binary links only what every Mac already has.

**Shipped** in `src/coreaudio_sink.{h,cpp}`, built on macOS with nothing to find
(`-DSENDSPIN_CLI_WITH_COREAUDIO=OFF` forces it out):

- An `AudioSink` over AUHAL (`kAudioUnitSubType_HALOutput`), modelled on
`src/portaudio_sink.{h,cpp}` — the other callback-driven sink — and reusing
`PcmRingBuffer`, `pcm_volume.{h,cpp}` and `SinkRecovery`/`OutageGapHandoff` rather than
reimplementing them. The four invariants `portaudio_sink.h` writes down come from the
`AudioSink` contract, not from PortAudio, so they are carried across unchanged.
- **Sync feedback through the render callback's `AudioTimeStamp`.** PortAudio hands the
callback `outputBufferDacTime` against `currentTime`; AUHAL hands a Mach absolute time, so
the distance is taken in Mach ticks and converted through `mach_timebase_info()`. That
timestamp is when the hardware *consumes* the buffer rather than when it reaches the speaker,
so `kAudioDevicePropertyLatency`, `kAudioStreamPropertyLatency` and the unit's converter
latency go on top — unlike PortAudio, where `outputBufferDacTime` already carries them and
adding them would count twice. **`kAudioDevicePropertySafetyOffset` is deliberately not in
that sum**: it is the margin the HAL schedules ahead by, so it is already in how far in the
future the timestamp sits. Adding it too is an easy mistake — it cost this backend a
systematic ~33-64 frames before the timestamp's meaning was checked — and it is why a
whole-path latency figure of the kind mpv computes, which also folds in
`kAudioDevicePropertyBufferFrameSize`, is the wrong thing to add to a per-buffer timestamp.
- **8/16/24/32-bit** through a packed signed little-endian `AudioStreamBasicDescription` on
the unit's input scope, letting the AU convert to whatever the device is running. That is
what keeps every depth the decoders emit without a converter of our own, and
`capabilities()` probes the three ladders by setting each candidate format on a scratch
unit — the same question a stream asks.
- **`-o coreaudio[:<device>]`**, with PortAudio's device grammar exactly: bare means this
host's current default output, `coreaudio:2` an index as `-l` prints it, and
`coreaudio:<name>` a full case-insensitive match, with an ambiguous name refused naming the
candidates. The device is re-resolved at every stream, and `-o` defaults to `coreaudio`
wherever it is the most direct backend, so a bare run plays on a Mac.
- **In-place device recovery without the rescan cycle item 14 needed.** An `AudioDeviceID` is
stable, and `AudioObjectAddPropertyListener` reports `kAudioDevicePropertyDeviceIsAlive`
directly, so there is no `Pa_Terminate()`/`Pa_Initialize()` and no index renumbering: the
listener flags the death off its HAL thread, `write()` spends the one in-place reopen, and
`poll()` retries on `SinkRecovery`'s backoff.
- **A moved system default is followed, and is not a recovery.** A bare `-o coreaudio` also
listens on `kAudioHardwarePropertyDefaultOutputDevice`; `poll()` reopens on the new device
with the ring tail accounted as an outage gap. Deliberately outside `SinkRecovery`'s budget:
a default move is an ordinary event, and spending the reopen on it would leave a real outage
in the same track with nothing left.
- **The guard that would have caught this.** CI's macOS leg runs `otool -L` over the staged
binary and fails on any load command outside `/usr/lib` and `/System`. Worth having whatever
the backend is, and it is the only part of this item a runner can prove — every
`scripts/smoke_test.sh` invocation is `-o null`, so no runner opens a device.
- CI's `macos-arm64` leg no longer runs `brew install portaudio pkgconf`, its
`expect_backends` is `null, stdout, coreaudio`, and its `runtime_packages` is the fact that
there are none. `with_portaudio` joins the matrix on **every** leg, since a key missing from
one reads as false.

**Not in this slice:**

- **Removing PortAudio.** It stays in the tree and stays `ON` everywhere but the macOS release
leg; `-o portaudio` still works on Linux, and a Mac that asks for it still builds both.
Deleting it is a separate call once CoreAudio has field time.
- **Hardware volume** → item 15, as for every other backend.
- **Setting the device's nominal rate to match the stream.** The AU resamples instead, which is
what keeps a device pinned at 48 kHz playing 44.1 kHz content without stealing it from
whatever else is using it.

**What has and has not been exercised.** It compiles clean under `-Werror` on CI's
`macos-arm64` leg — which is the first compiler that saw it, since it was written on Linux,
where it is not built — and that leg's `otool -L` guard reports only CoreAudio, AudioToolbox,
CoreFoundation, `libc++` and `libSystem`, so the dyld abort this item exists to fix cannot
come back unnoticed. The parser-level `-o coreaudio[:...]` forms and the Linux
reserved-backend refusal are covered in `tests/device_spec_test.cpp` and run on every leg.
None of that opens a device. Still owed, on a Mac with real output:
a clean tone at 48 kHz/16-bit, 44.1 kHz/24-bit and 44.1 kHz/32-bit; exact `on_frames_played`
accounting against the wall clock; a DAC offset plausible against the device's reported
latency; a mid-stream format change and recovery from a refused one; volume, mute, the ramp
across a change and the shutdown latch; a default-output move and an unplug while playing; and
a real Sendspin server driving it end to end. Item 3 shipped without that last one and said so;
this item should not.
4 changes: 2 additions & 2 deletions docs/wiki/Advanced-Usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ 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
pair split on the first colon (`coreaudio:2`, `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
Expand Down
2 changes: 1 addition & 1 deletion docs/wiki/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ already does that.

| Key | Same as | Value | Default |
|---|---|---|---|
| `output` | `-o`, `--output` | a device: `hw:1,0`, `default`, `portaudio:2`, `null`, `stdout` | `default` where ALSA is built in, else `portaudio`, else `null` |
| `output` | `-o`, `--output` | a device: `hw:1,0`, `default`, `coreaudio:2`, `portaudio:2`, `null`, `stdout` | `default` where ALSA is built in, else `coreaudio`, else `portaudio`, else `null` |
| `name` | `-n`, `--name` | the friendly name a controller shows | this host's name |
| `id` | `--id` | the stable client id a server files this player's settings under — two players on one host must not share it | derived from the interface MAC |
| `manufacturer` | `--manufacturer` | the manufacturer `client/hello` reports | `sendspin-cpp-cli` |
Expand Down
6 changes: 3 additions & 3 deletions docs/wiki/Home.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ server discovers the advertisement and dials in. `-s mdns:` inverts it: this pla
discovers a server and dials it, which the protocol treats as the other of two mutually exclusive
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
names. The player is also driven from its own host over a Unix socket:
Audio goes out through ALSA (the Linux default), CoreAudio (the macOS default) or PortAudio,
PulseAudio and PipeWire where they are built in, with volume applied in software on a curve
the spec names. The player is also driven from its own host over a Unix socket:

```console
$ sendspin-cli status
Expand Down
7 changes: 6 additions & 1 deletion docs/wiki/Installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ sudo installer -pkg sendspin-cli-0.1.0-macos-arm64.pkg -target /
sendspin-cli --version
```

**A released macOS build needs nothing installed alongside it.** Audio goes out through
CoreAudio and discovery through Bonjour, both part of macOS, so there is no Homebrew step
and `otool -L` on the shipped binary shows only `/usr/lib` and `/System`.

It refuses a Mac it cannot run on — the architectures are read off the binary at build time
and declared in the package — so an Intel Mac is turned away rather than told the install
worked. To undo it: remove the four files and
Expand Down Expand Up @@ -152,7 +156,8 @@ against a different version of the library.
```bash
sudo apt install pkg-config libasound2-dev portaudio19-dev libavahi-compat-libdnssd-dev # Debian / Ubuntu
sudo dnf install pkgconf alsa-lib-devel portaudio-devel avahi-compat-libdns_sd-devel # Fedora / RHEL
brew install portaudio pkgconf # macOS
# macOS needs nothing: CoreAudio and Bonjour are part of the system. For the optional
# PortAudio backend as well, `brew install portaudio pkgconf`.

git clone https://github.com/Sendspin/sendspin-cpp-cli.git
cd sendspin-cpp-cli
Expand Down
4 changes: 2 additions & 2 deletions docs/wiki/Troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,8 +335,8 @@ Raise the buffer. The default is 100 ms, and the range is 10–2000:
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
That is one figure for every backend — ALSA divides it into periods, CoreAudio and PortAudio
make 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 stream hooks](Advanced-Usage#buffering-and-stream-hooks).

Expand Down
Loading