diff --git a/.github/workflows/build-armv6.yml b/.github/workflows/build-armv6.yml index ab1f97c..399240a 100644 --- a/.github/workflows/build-armv6.yml +++ b/.github/workflows/build-armv6.yml @@ -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" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 887c141..6464bb7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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' @@ -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' @@ -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' @@ -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 @@ -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$' @@ -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 @@ -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 @@ -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" @@ -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 **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. @@ -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[:]`**, with PortAudio's device grammar exactly: bare means this + host's current default output, `coreaudio:2` an index as `-l` prints it, and + `coreaudio:` 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. diff --git a/docs/wiki/Advanced-Usage.md b/docs/wiki/Advanced-Usage.md index f0b104b..4578dd8 100644 --- a/docs/wiki/Advanced-Usage.md +++ b/docs/wiki/Advanced-Usage.md @@ -45,8 +45,8 @@ 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 +pair split on the first colon (`coreaudio:2`, `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 diff --git a/docs/wiki/Configuration.md b/docs/wiki/Configuration.md index 3ea91cd..3691144 100644 --- a/docs/wiki/Configuration.md +++ b/docs/wiki/Configuration.md @@ -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` | diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index fb7b646..71f3e02 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -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 diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md index 5f198f6..dea28a2 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -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 @@ -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 diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md index 0cb30cd..de027ec 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/wiki/Troubleshooting.md @@ -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). diff --git a/src/audio_sink.cpp b/src/audio_sink.cpp index 2af208a..0582db8 100644 --- a/src/audio_sink.cpp +++ b/src/audio_sink.cpp @@ -21,6 +21,10 @@ #include "alsa_sink.h" #endif +#ifdef SENDSPIN_CLI_HAVE_COREAUDIO +#include "coreaudio_sink.h" +#endif + #ifdef SENDSPIN_CLI_HAVE_PORTAUDIO #include "portaudio_sink.h" #endif @@ -64,6 +68,9 @@ constexpr BuiltBackend BUILT_BACKENDS[] = { #ifdef SENDSPIN_CLI_HAVE_ALSA {"alsa", SinkBackend::Alsa, DeviceArg::Required}, #endif +#ifdef SENDSPIN_CLI_HAVE_COREAUDIO + {"coreaudio", SinkBackend::CoreAudio, DeviceArg::Optional}, +#endif #ifdef SENDSPIN_CLI_HAVE_PORTAUDIO {"portaudio", SinkBackend::PortAudio, DeviceArg::Optional}, #endif @@ -89,6 +96,10 @@ constexpr ReservedBackend RESERVED_BACKENDS[] = { "the ALSA backend is not in this build -- libasound was missing, or it was configured " "with -DSENDSPIN_CLI_WITH_ALSA=OFF", nullptr}, + {"coreaudio", + "the CoreAudio backend is not in this build -- it is macOS-only, and even there it can " + "be configured out with -DSENDSPIN_CLI_WITH_COREAUDIO=OFF", + nullptr}, {"portaudio", "the PortAudio backend is not in this build -- libportaudio was missing, or it was " "configured with -DSENDSPIN_CLI_WITH_PORTAUDIO=OFF", @@ -257,6 +268,9 @@ bool resolve_device_spec(const std::string& spec, DeviceSpec& out, std::string& return true; #else error = "unknown output device '" + spec + "' -- this build has: " + audio_backend_list(); +#ifdef SENDSPIN_CLI_HAVE_COREAUDIO + error += ". A CoreAudio device needs its prefix: -o coreaudio:" + spec; +#endif #ifdef SENDSPIN_CLI_HAVE_PORTAUDIO error += ". A PortAudio device needs its prefix: -o portaudio:" + spec; #endif @@ -310,6 +324,16 @@ std::unique_ptr make_audio_sink(const std::string& device, return std::make_unique(spec.device, buffer_ms); #else break; // unreachable: resolve_device_spec() never yields Alsa without the backend +#endif + case SinkBackend::CoreAudio: +#ifdef SENDSPIN_CLI_HAVE_COREAUDIO + // Probed now so a typo fails at startup, not at the first stream. + if (!CoreAudioSink::probe(spec.device, error)) { + return nullptr; + } + return std::make_unique(spec.device, buffer_ms); +#else + break; // unreachable, for the same reason as Alsa above #endif case SinkBackend::PortAudio: #ifdef SENDSPIN_CLI_HAVE_PORTAUDIO @@ -351,6 +375,9 @@ void print_audio_devices(std::FILE* out) { std::fprintf(out, " null discard audio; needs no sound card at all\n"); std::fprintf(out, " stdout raw interleaved PCM on stdout, e.g. | aplay -f cd\n"); std::fprintf(out, " - alias for stdout\n"); +#ifdef SENDSPIN_CLI_HAVE_COREAUDIO + std::fprintf(out, " coreaudio this host's default output device, whatever it currently is\n"); +#endif #ifdef SENDSPIN_CLI_HAVE_PORTAUDIO std::fprintf(out, " portaudio this host's default output device, whatever it currently is\n"); #endif @@ -376,10 +403,9 @@ void print_audio_devices(std::FILE* out) { #else std::fprintf(out, " 3. anything else would be an ALSA PCM name, but this build has no ALSA\n" " backend, so only the forms above resolve here.\n"); -#ifdef SENDSPIN_CLI_HAVE_PORTAUDIO - std::fprintf(out, - " A PortAudio device is reached through its prefix, never bare -- see the\n" - " device list below.\n"); +#if defined(SENDSPIN_CLI_HAVE_COREAUDIO) || defined(SENDSPIN_CLI_HAVE_PORTAUDIO) + std::fprintf(out, " A CoreAudio or PortAudio device is reached through its prefix, never\n" + " bare -- see the device list below.\n"); #endif #endif @@ -444,6 +470,23 @@ void print_audio_devices(std::FILE* out) { "while one is running.\n"); #endif +#ifdef SENDSPIN_CLI_HAVE_COREAUDIO + std::fprintf(out, "\nCoreAudio output devices on this host (-o coreaudio:):\n"); + CoreAudioSink::list_devices(out); + std::fprintf(out, + "\nThe name is the form worth writing down. An index is a position in the list\n" + "above, so it shifts as devices come and go; a name is matched in full and\n" + "case-insensitively, and one that matches more than one device is refused\n" + "rather than guessed at. -o coreaudio with no device at all follows this\n" + "host's default output, resolved afresh at every stream and again whenever\n" + "macOS moves the default while one is playing.\n" + "\nInput-only devices are left out, since -o cannot play through them. The\n" + "rate on the device's own line is the one it is running at; the rates below it\n" + "are what the output unit will take, asked for the same way a stream would\n" + "ask. Only the four formats sendspin-cli can emit are shown, and the unit\n" + "converts on top of them where the device itself will not take one.\n"); +#endif + #ifdef SENDSPIN_CLI_HAVE_PORTAUDIO std::fprintf(out, "\nPortAudio output devices on this host (-o portaudio:):\n"); PortAudioSink::list_devices(out); diff --git a/src/audio_sink.h b/src/audio_sink.h index 24cced2..96f569e 100644 --- a/src/audio_sink.h +++ b/src/audio_sink.h @@ -119,6 +119,7 @@ enum class SinkBackend { Null, ///< discard everything; needs no device Stdout, ///< raw interleaved PCM on stdout; needs no device Alsa, ///< an ALSA PCM, named by DeviceSpec::device + CoreAudio, ///< a CoreAudio device by index or name, or this host's default if empty PortAudio, ///< a PortAudio device by index or name, or this host's default if empty Pulse, ///< a PulseAudio sink by name, or the server's own default if empty PipeWire, ///< a PipeWire node by name, or the graph's own default routing if empty diff --git a/src/cli.h b/src/cli.h index 6e74433..65313a5 100644 --- a/src/cli.h +++ b/src/cli.h @@ -35,6 +35,8 @@ inline constexpr const char* DISCOVERY_PREFIX = "mdns:"; /// The -o default: the most direct real backend this build has, else `null`. #ifdef SENDSPIN_CLI_HAVE_ALSA inline constexpr const char* DEFAULT_OUTPUT_DEVICE = "default"; +#elif defined(SENDSPIN_CLI_HAVE_COREAUDIO) +inline constexpr const char* DEFAULT_OUTPUT_DEVICE = "coreaudio"; #elif defined(SENDSPIN_CLI_HAVE_PORTAUDIO) inline constexpr const char* DEFAULT_OUTPUT_DEVICE = "portaudio"; #elif defined(SENDSPIN_CLI_HAVE_PULSE) diff --git a/src/coreaudio_sink.cpp b/src/coreaudio_sink.cpp new file mode 100644 index 0000000..ed9d266 --- /dev/null +++ b/src/coreaudio_sink.cpp @@ -0,0 +1,1159 @@ +// Copyright 2026 sendspin-cpp-cli Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "coreaudio_sink.h" + +#include "log.h" +#include "pcm_volume.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace sendspin_cli { + +using sendspin::LogLevel; + +static constexpr const char* LOG_TAG = LOG_TAG_AUDIO; + +namespace { + +/// Ring floor in device buffers: the callback takes a whole buffer per wakeup. +constexpr double RING_LATENCY_MULTIPLE = 3.0; + +/// Absolute floor on the ring, for a device that reports no latency at all. +constexpr size_t MIN_RING_FRAMES = 1024; + +/// CoreAudio's spelling of PROBE_BIT_DEPTHS, in the same order. +constexpr std::array PROBE_FORMAT_NAMES{"SInt8", "SInt16", + "SInt24", "SInt32"}; + +int64_t now_us() { + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); +} + +/// Mach absolute-time ticks per microsecond; 0 when the timebase cannot be read. +double host_ticks_per_us() { + mach_timebase_info_data_t timebase{}; + if (mach_timebase_info(&timebase) != KERN_SUCCESS || timebase.numer == 0) { + return 0.0; + } + // A tick is numer/denom nanoseconds, so a microsecond is 1000 * denom/numer ticks. + return 1000.0 * static_cast(timebase.denom) / static_cast(timebase.numer); +} + +AudioObjectPropertyAddress address_of( + AudioObjectPropertySelector selector, + AudioObjectPropertyScope scope = kAudioObjectPropertyScopeGlobal) { + return {selector, scope, kAudioObjectPropertyElementMain}; +} + +/// Renders an OSStatus the way CoreAudio headers write one: a four-character code where it is. +std::string os_status_text(OSStatus status) { + char code[5] = {}; + const auto value = static_cast(status); + for (int i = 0; i < 4; ++i) { + code[i] = static_cast((value >> (24 - (8 * i))) & 0xFFU); + if (std::isprint(static_cast(code[i])) == 0) { + return std::to_string(static_cast(status)); + } + } + return std::string("'") + code + "' (" + std::to_string(static_cast(status)) + ")"; +} + +std::string cf_string_to_utf8(CFStringRef value) { + if (value == nullptr) { + return {}; + } + const CFIndex capacity = + CFStringGetMaximumSizeForEncoding(CFStringGetLength(value), kCFStringEncodingUTF8) + 1; + std::string out(static_cast(capacity), '\0'); + if (CFStringGetCString(value, out.data(), capacity, kCFStringEncodingUTF8) == 0) { + return {}; + } + out.resize(std::strlen(out.c_str())); + return out; +} + +/// The name macOS shows for a device, or "(unknown device)" when it cannot be read. +std::string device_name(AudioDeviceID device) { + const AudioObjectPropertyAddress addr = address_of(kAudioObjectPropertyName); + CFStringRef value = nullptr; + UInt32 size = sizeof(value); + if (AudioObjectGetPropertyData(device, &addr, 0, nullptr, &size, &value) != noErr || + value == nullptr) { + return "(unknown device)"; + } + std::string name = cf_string_to_utf8(value); + CFRelease(value); + return name.empty() ? "(unknown device)" : name; +} + +/// A UInt32 property, or 0 when it cannot be read. +uint32_t u32_property(AudioObjectID object, AudioObjectPropertySelector selector, + AudioObjectPropertyScope scope) { + const AudioObjectPropertyAddress addr = address_of(selector, scope); + UInt32 value = 0; + UInt32 size = sizeof(value); + if (AudioObjectGetPropertyData(object, &addr, 0, nullptr, &size, &value) != noErr) { + return 0; + } + return value; +} + +/// Total output channels across the device's output streams; 0 means it cannot be played through. +uint32_t output_channels(AudioDeviceID device) { + const AudioObjectPropertyAddress addr = + address_of(kAudioDevicePropertyStreamConfiguration, kAudioDevicePropertyScopeOutput); + UInt32 size = 0; + if (AudioObjectGetPropertyDataSize(device, &addr, 0, nullptr, &size) != noErr || size == 0) { + return 0; + } + // Through a 64-bit vector: an AudioBufferList holds pointers and must be aligned for them. + std::vector storage((size + sizeof(uint64_t) - 1) / sizeof(uint64_t)); + auto* list = reinterpret_cast(storage.data()); + if (AudioObjectGetPropertyData(device, &addr, 0, nullptr, &size, list) != noErr) { + return 0; + } + uint32_t total = 0; + for (UInt32 i = 0; i < list->mNumberBuffers; ++i) { + total += list->mBuffers[i].mNumberChannels; + } + return total; +} + +/// The rate the device itself is running at; 0 when it cannot be read. +double nominal_sample_rate(AudioDeviceID device) { + const AudioObjectPropertyAddress addr = address_of(kAudioDevicePropertyNominalSampleRate); + Float64 value = 0.0; + UInt32 size = sizeof(value); + if (AudioObjectGetPropertyData(device, &addr, 0, nullptr, &size, &value) != noErr) { + return 0.0; + } + return value; +} + +/// Presentation latency of the device's first output stream, in frames. +uint32_t first_output_stream_latency(AudioDeviceID device) { + const AudioObjectPropertyAddress addr = + address_of(kAudioDevicePropertyStreams, kAudioDevicePropertyScopeOutput); + UInt32 size = 0; + if (AudioObjectGetPropertyDataSize(device, &addr, 0, nullptr, &size) != noErr || + size < sizeof(AudioStreamID)) { + return 0; + } + std::vector streams(size / sizeof(AudioStreamID)); + if (AudioObjectGetPropertyData(device, &addr, 0, nullptr, &size, streams.data()) != noErr) { + return 0; + } + return u32_property(streams.front(), kAudioStreamPropertyLatency, + kAudioObjectPropertyScopeGlobal); +} + +AudioDeviceID default_output_device() { + const AudioObjectPropertyAddress addr = address_of(kAudioHardwarePropertyDefaultOutputDevice); + AudioDeviceID device = kAudioObjectUnknown; + UInt32 size = sizeof(device); + if (AudioObjectGetPropertyData(kAudioObjectSystemObject, &addr, 0, nullptr, &size, &device) != + noErr) { + return kAudioObjectUnknown; + } + return device; +} + +/// This host's output-capable devices, in HAL order; the position is the index -o and -l use. +std::vector enumerate_output_devices() { + const AudioObjectPropertyAddress addr = address_of(kAudioHardwarePropertyDevices); + UInt32 size = 0; + if (AudioObjectGetPropertyDataSize(kAudioObjectSystemObject, &addr, 0, nullptr, &size) != + noErr || + size < sizeof(AudioDeviceID)) { + return {}; + } + std::vector all(size / sizeof(AudioDeviceID)); + if (AudioObjectGetPropertyData(kAudioObjectSystemObject, &addr, 0, nullptr, &size, + all.data()) != noErr) { + return {}; + } + + std::vector outputs; + for (const AudioDeviceID device : all) { + if (output_channels(device) > 0) { + outputs.push_back(device); + } + } + return outputs; +} + +/// True if `value` is a non-empty run of decimal digits. +bool is_device_index(const std::string& value) { + if (value.empty()) { + return false; + } + return value.find_first_not_of("0123456789") == std::string::npos; +} + +bool iequals(const std::string& value, const std::string& other) { + if (value.size() != other.size()) { + return false; + } + for (size_t i = 0; i < value.size(); ++i) { + // Through unsigned char: tolower() is undefined for negative chars. + const int a = std::tolower(static_cast(value[i])); + const int b = std::tolower(static_cast(other[i])); + if (a != b) { + return false; + } + } + return true; +} + +/// Resolves a CoreAudio device spec: empty is the default, digits an index, else a unique name. +bool resolve_ca_device(const std::string& device, AudioDeviceID& out, std::string& error) { + if (device.empty()) { + const AudioDeviceID fallback = default_output_device(); + if (fallback == kAudioObjectUnknown) { + error = "this host has no default CoreAudio output device at all -- run with -l to " + "see what it does have"; + return false; + } + out = fallback; + return true; + } + + const std::vector outputs = enumerate_output_devices(); + if (outputs.empty()) { + error = "this host has no CoreAudio output devices at all"; + return false; + } + + if (is_device_index(device)) { + // strtoull saturates rather than wrapping into a plausible index. + const unsigned long long value = std::strtoull(device.c_str(), nullptr, 10); + if (value >= outputs.size()) { + error = "-o coreaudio:" + device + ": no device at that index -- indices run 0-" + + std::to_string(outputs.size() - 1) + " here, and -l lists them"; + return false; + } + out = outputs[static_cast(value)]; + return true; + } + + std::vector matches; + for (size_t i = 0; i < outputs.size(); ++i) { + if (iequals(device, device_name(outputs[i]))) { + matches.push_back(i); + } + } + if (matches.empty()) { + error = "-o coreaudio:" + device + + ": no output device by that name -- run with -l to list them"; + return false; + } + if (matches.size() > 1) { + std::string indices; + for (const size_t index : matches) { + if (!indices.empty()) { + indices += ", "; + } + indices += std::to_string(index); + } + error = "-o coreaudio:" + device + ": " + std::to_string(matches.size()) + + " output devices share that name (indices " + indices + + ") -- name the one you mean by index instead"; + return false; + } + + out = outputs[matches.front()]; + return true; +} + +/// Maps a stream format onto the interleaved signed little-endian PCM an AUHAL input scope takes. +bool asbd_for(uint32_t sample_rate, uint8_t channels, uint8_t bits_per_sample, + AudioStreamBasicDescription& out) { + if (sample_rate == 0 || channels == 0) { + return false; + } + if (std::find(PROBE_BIT_DEPTHS.begin(), PROBE_BIT_DEPTHS.end(), bits_per_sample) == + PROBE_BIT_DEPTHS.end()) { + return false; + } + out = {}; + out.mSampleRate = static_cast(sample_rate); + out.mFormatID = kAudioFormatLinearPCM; + // Little-endian is the absence of kAudioFormatFlagIsBigEndian; packed rules out 24-in-32. + out.mFormatFlags = kAudioFormatFlagIsSignedInteger | kAudioFormatFlagIsPacked; + out.mBitsPerChannel = bits_per_sample; + out.mChannelsPerFrame = channels; + out.mFramesPerPacket = 1; + out.mBytesPerFrame = static_cast(channels) * (bits_per_sample / 8U); + out.mBytesPerPacket = out.mBytesPerFrame; + return true; +} + +/// A fresh, uninitialized AUHAL output unit, or nullptr. +AudioUnit new_hal_unit() { + AudioComponentDescription description = {}; + description.componentType = kAudioUnitType_Output; + description.componentSubType = kAudioUnitSubType_HALOutput; + description.componentManufacturer = kAudioUnitManufacturer_Apple; + + AudioComponent component = AudioComponentFindNext(nullptr, &description); + if (component == nullptr) { + return nullptr; + } + AudioUnit unit = nullptr; + if (AudioComponentInstanceNew(component, &unit) != noErr) { + return nullptr; + } + return unit; +} + +/// Asks the unit whether it will take this exact format on its input scope; the AU converts on +/// top of what the device itself does, so this is what a stream would really get. +bool unit_accepts(AudioUnit unit, uint32_t sample_rate, uint8_t channels, uint8_t bits) { + AudioStreamBasicDescription asbd = {}; + if (!asbd_for(sample_rate, channels, bits, asbd)) { + return false; + } + return AudioUnitSetProperty(unit, kAudioUnitProperty_StreamFormat, kAudioUnitScope_Input, 0, + &asbd, sizeof(asbd)) == noErr; +} + +/// Why a probe could not describe a device. +enum class ProbeStatus { + Ok, + NoDevice, ///< the device has no output channels, or it went away mid-probe + NoUnit, ///< AUHAL would not instantiate, so nothing can be asked +}; + +struct ProbeResult { + ProbeStatus status{ProbeStatus::Ok}; + /// Empty on anything but Ok. + SinkCapabilities caps; +}; + +/// Asks one device what it will take, without starting it; axes are independent. +ProbeResult probe_capabilities(AudioDeviceID device) { + ProbeResult result; + const uint32_t max_channels = output_channels(device); + if (max_channels == 0) { + result.status = ProbeStatus::NoDevice; + return result; + } + + AudioUnit unit = new_hal_unit(); + if (unit == nullptr) { + result.status = ProbeStatus::NoUnit; + return result; + } + if (AudioUnitSetProperty(unit, kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, + 0, &device, sizeof(device)) != noErr) { + AudioComponentInstanceDispose(unit); + result.status = ProbeStatus::NoDevice; + return result; + } + + // One rate x depth pass at the channel count we would use. + const auto probe_channels = static_cast(std::min(2, max_channels)); + bool accepted[PROBE_RATES.size()][PROBE_BIT_DEPTHS.size()] = {}; + for (size_t r = 0; r < PROBE_RATES.size(); ++r) { + for (size_t d = 0; d < PROBE_BIT_DEPTHS.size(); ++d) { + accepted[r][d] = + unit_accepts(unit, PROBE_RATES[r], probe_channels, PROBE_BIT_DEPTHS[d]); + } + } + + for (size_t r = 0; r < PROBE_RATES.size(); ++r) { + if (std::any_of(std::begin(accepted[r]), std::end(accepted[r]), + [](bool ok) { return ok; })) { + result.caps.rates.push_back(PROBE_RATES[r]); + } + } + for (size_t d = 0; d < PROBE_BIT_DEPTHS.size(); ++d) { + for (size_t r = 0; r < PROBE_RATES.size(); ++r) { + if (accepted[r][d]) { + result.caps.bit_depths.push_back(PROBE_BIT_DEPTHS[d]); + break; + } + } + } + + // The channel axis reuses a depth the grid accepted, at the device's own rate. + if (!result.caps.bit_depths.empty()) { + const double device_rate = nominal_sample_rate(device); + const auto probe_rate = + (device_rate > 0.0) ? static_cast(device_rate) : result.caps.rates.front(); + for (const uint8_t count : PROBE_CHANNELS) { + if (count > max_channels) { + break; // PROBE_CHANNELS ascends, so nothing after this fits either + } + if (unit_accepts(unit, probe_rate, count, result.caps.bit_depths.front())) { + result.caps.channels.push_back(count); + } + } + } + + AudioComponentInstanceDispose(unit); + return result; +} + +/// Prints what one device will take, indented under it in -l. +void print_device_capabilities(std::FILE* out, AudioDeviceID device) { + const ProbeResult result = probe_capabilities(device); + switch (result.status) { + case ProbeStatus::NoDevice: + std::fprintf(out, " (cannot query: the device went away)\n"); + return; + case ProbeStatus::NoUnit: + std::fprintf(out, " (cannot query: no AUHAL output unit on this host)\n"); + return; + case ProbeStatus::Ok: + break; + } + print_sink_capabilities(out, result.caps, PROBE_FORMAT_NAMES); +} + +} // namespace + +CoreAudioSink::CoreAudioSink(std::string device, uint32_t buffer_ms) + : device_(std::move(device)), buffer_ms_(buffer_ms) {} + +CoreAudioSink::~CoreAudioSink() { + // A sink destroyed without stop() must still stop its unit and drop its listeners. + this->stopping_.store(true); + this->space_available_.notify_all(); + + const std::lock_guard lock(this->mutex_); + this->close_unit_(); +} + +std::string CoreAudioSink::name() const { + return this->device_.empty() ? "coreaudio" : "coreaudio:" + this->device_; +} + +bool CoreAudioSink::probe(const std::string& device, std::string& error) { + AudioDeviceID resolved = kAudioObjectUnknown; + return resolve_ca_device(device, resolved, error); +} + +SinkCapabilities CoreAudioSink::capabilities() const { + // Describes the default device at startup; a later default move shows up as a reopen. + AudioDeviceID device = kAudioObjectUnknown; + std::string error; + if (!resolve_ca_device(this->device_, device, error)) { + cli_log(LogLevel::DEBUG, "coreaudio: %s -- advertising everything sendspin-cli can emit", + error.c_str()); + return SinkCapabilities::permissive(); + } + + const std::string label = device_name(device); + const ProbeResult result = probe_capabilities(device); + if (result.status != ProbeStatus::Ok) { + cli_log(LogLevel::DEBUG, + "coreaudio: could not probe '%s' -- advertising everything sendspin-cli can emit", + label.c_str()); + return SinkCapabilities::permissive(); + } + cli_log(LogLevel::DEBUG, "coreaudio: capabilities probed from '%s'", label.c_str()); + return result.caps; +} + +void CoreAudioSink::list_devices(std::FILE* out) { + const std::vector outputs = enumerate_output_devices(); + if (outputs.empty()) { + std::fprintf(out, " (this host has no CoreAudio output devices)\n"); + return; + } + + const AudioDeviceID fallback = default_output_device(); + std::fprintf(out, " idx name out ch default rate\n"); + for (size_t i = 0; i < outputs.size(); ++i) { + const AudioDeviceID device = outputs[i]; + std::fprintf(out, " %3zu %-38s %2u ch %6.0f Hz%s\n", i, device_name(device).c_str(), + output_channels(device), nominal_sample_rate(device), + (device == fallback) ? " (system default)" : ""); + print_device_capabilities(out, device); + } +} + +bool CoreAudioSink::configure(uint32_t sample_rate, uint8_t channels, uint8_t bits_per_sample) { + const std::lock_guard lock(this->mutex_); + + if (this->stopping_.load()) { + // A stream start racing shutdown; never clear stopping_ here. + cli_log(LogLevel::DEBUG, "coreaudio: ignoring a stream start during shutdown"); + return false; + } + + // Before anything can fail: recovery reopens at this format. + this->last_format_ = {sample_rate, channels, bits_per_sample}; + // A new stream starts the producer from zero buffered frames, so an outage gap left over from + // the last one is owed to nobody, on either side of the handoff. Before the device is + // resolved, so the restart_unit_() reuse path and a failed open both drop it too. + this->recovery_.forget_discarded_frames(); + this->gap_handoff_.forget(); + this->default_moved_.store(false); + + // Resolved per stream, so a bare -o coreaudio follows the host's default. + AudioDeviceID device = kAudioObjectUnknown; + std::string error; + if (!resolve_ca_device(this->device_, device, error)) { + cli_log(LogLevel::ERROR, "coreaudio: %s", error.c_str()); + this->failed_.store(true); + return false; + } + + if (this->unit_alive_() && this->device_id_ == device && this->rate_ == sample_rate && + this->channels_ == channels && this->bits_ == bits_per_sample) { + // Same device and format: restart from an empty ring instead of reopening. + if (this->restart_unit_()) { + this->recovery_.reset(); + cli_log(LogLevel::DEBUG, "coreaudio: reusing the open unit at %u Hz, %u ch, %u-bit", + sample_rate, channels, bits_per_sample); + return true; + } + cli_log(LogLevel::WARN, "coreaudio: could not restart the unit -- reopening"); + } + + this->close_unit_(); + if (!this->open_unit_(device, sample_rate, channels, bits_per_sample)) { + this->failed_.store(true); + return false; + } + // Refill the recovery budget only once a unit is really running. + this->recovery_.reset(); + return true; +} + +size_t CoreAudioSink::write(const uint8_t* data, size_t length, uint32_t timeout_ms) { + if (data == nullptr || length == 0) { + return 0; + } + + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms); + std::unique_lock lock(this->mutex_); + + if ((!this->unit_alive_() || this->bytes_per_frame_ == 0) && !this->reopen_in_place_()) { + // Discard rather than return 0 forever, which would spin the sync task. + if (!this->failed_.exchange(true)) { + cli_log(LogLevel::ERROR, + "coreaudio: '%s' is not playing -- discarding audio until a stream " + "reconfigures it", + this->name().c_str()); + } + // Frame-aligned via last_format_ when a failed reopen has zeroed bytes_per_frame_. + const size_t frame = (this->bytes_per_frame_ != 0) + ? this->bytes_per_frame_ + : static_cast(this->last_format_.channels) * + (static_cast(this->last_format_.bit_depth) / 8U); + const size_t consumed = (frame == 0) ? length : length - (length % frame); + if (frame != 0) { + this->recovery_.discard_frames(static_cast(consumed / frame)); + } + return consumed; + } + + const size_t bytes_per_frame = this->bytes_per_frame_; + // Re-checked after every wait: waiting drops the mutex. + const uint64_t generation = this->stream_generation_; + const size_t usable = length - (length % bytes_per_frame); + if (usable == 0) { + return 0; + } + + // The unit is alive, so an outage's gap can go to the callback: it has the timestamp to + // retire the gap against, and this thread does not. + this->gap_handoff_.add(this->recovery_.take_discarded_frames()); + + size_t done = 0; + while (done < usable) { + if (this->stopping_.load()) { + break; + } + + const size_t remaining = usable - done; + size_t room = this->ring_.free_space(); + if (room < remaining) { + // Whole frames only: the callback counts frames by integer division. + room -= room % bytes_per_frame; + } + if (room > 0) { + const size_t written = this->ring_.write(data + done, std::min(remaining, room)); + if (written == 0) { + break; // the ring reported room and then took none; nothing to gain by spinning + } + done += written; + continue; + } + + if (!this->space_available_.wait_until(lock, deadline, [this, bytes_per_frame, generation] { + return this->ring_.free_space() >= bytes_per_frame || this->stopping_.load() || + this->unit_ == nullptr || this->stream_generation_ != generation; + })) { + break; // out of time; the caller gets a short write and comes back + } + if (this->unit_ == nullptr || this->stream_generation_ != generation) { + // The unit changed while unlocked; report only what landed. + break; + } + } + + return done; +} + +void CoreAudioSink::clear() { + const std::lock_guard lock(this->mutex_); + + // A flush ends a parked write()'s stream. + ++this->stream_generation_; + + // The player zeroes its buffered-frame count with a flush, so the gap is owed to nobody. Not + // reset(): the recovery budget is configure()'s to refill. + this->recovery_.forget_discarded_frames(); + this->gap_handoff_.forget(); + + // Do not snap current_multiplier_: the callback keeps running through a flush. + + if (this->callback_running_()) { + // The consumer owns read_pos_, so it drains on its next read. Liveness, not unit_alive_(): + // a lost device keeps being pulled, and drop() from this side would race that read. + this->ring_.request_clear(); + return; + } + // No consumer running, so drop now rather than leave a clear pending for the next stream. + this->ring_.drop(); +} + +void CoreAudioSink::stop() { + // Before the mutex, so a blocked write() bails out. + this->stopping_.store(true); + this->space_available_.notify_all(); + + const std::lock_guard lock(this->mutex_); + // Before the early return, so a write() after shutdown attempts nothing. + this->last_format_ = {}; + if (this->unit_ == nullptr) { + return; + } + this->close_unit_(); + cli_log(LogLevel::INFO, "coreaudio: '%s' closed", this->name().c_str()); +} + +void CoreAudioSink::poll(int64_t now_ms) { + if (this->stopping_.load()) { + return; + } + // A default move is an ordinary event, not a failure: it gets a clean reopen of its own so it + // cannot spend the recovery budget a real outage needs. + if (this->default_moved_.exchange(false)) { + this->follow_default_(); + } + + // Unlocked fast path, and checked before rescan_due() so shutdown never burns an attempt. + if (!this->recovery_.pending()) { + return; + } + + const std::lock_guard lock(this->mutex_); + if (this->last_format_.sample_rate == 0) { + return; // nothing was ever configured, so there is nothing to reopen at + } + if (!this->recovery_.rescan_due(now_ms)) { + return; + } + + AudioDeviceID device = kAudioObjectUnknown; + std::string error; + if (!resolve_ca_device(this->device_, device, error)) { + cli_log(LogLevel::WARN, + "coreaudio: '%s' is still gone -- discarding until it comes back " + "or the next stream (%s)", + this->name().c_str(), error.c_str()); + this->recovery_.rescan_done(false); + return; + } + + const StreamFormat format = this->last_format_; + this->discard_ring_tail_(); + this->close_unit_(); + if (!this->open_unit_(device, format.sample_rate, format.channels, format.bit_depth)) { + this->recovery_.rescan_done(false); // open_unit_() has already said why, once + return; + } + this->recovery_.rescan_done(true); + + if (this->stopping_.load()) { + // stop() can land during the slow cycle above; release the device now. + this->close_unit_(); + return; + } + cli_log(LogLevel::INFO, "coreaudio: '%s' is back, on '%s'", this->name().c_str(), + device_name(device).c_str()); +} + +void CoreAudioSink::set_volume(uint8_t volume) { + this->volume_.store(volume > 100 ? 100 : volume); + this->update_target_multiplier_(); + cli_log(LogLevel::DEBUG, "coreaudio: volume now %u", this->volume_.load()); +} + +void CoreAudioSink::set_muted(bool muted) { + this->muted_.store(muted); + this->update_target_multiplier_(); + cli_log(LogLevel::DEBUG, "coreaudio: %s", muted ? "muted" : "unmuted"); +} + +void CoreAudioSink::follow_default_() { + const std::lock_guard lock(this->mutex_); + if (!this->device_.empty() || this->unit_ == nullptr || this->last_format_.sample_rate == 0) { + return; + } + + AudioDeviceID device = kAudioObjectUnknown; + std::string error; + if (!resolve_ca_device(this->device_, device, error) || device == this->device_id_) { + return; // no default to move to, or it did not actually move + } + + const StreamFormat format = this->last_format_; + const std::string from = device_name(this->device_id_); + this->discard_ring_tail_(); + this->close_unit_(); + if (!this->open_unit_(device, format.sample_rate, format.channels, format.bit_depth)) { + // Spend the in-place attempt so poll()'s retry ladder takes over: write() cannot reopen a + // unit that is already disposed. + if (this->recovery_.reopen_due()) { + this->recovery_.reopen_done(false); + } + this->failed_.store(true); + return; + } + this->recovery_.reset(); + + if (this->stopping_.load()) { + this->close_unit_(); + return; + } + cli_log(LogLevel::INFO, + "coreaudio: the system default output moved from '%s' to '%s' -- following it", + from.c_str(), device_name(device).c_str()); +} + +bool CoreAudioSink::open_unit_(AudioDeviceID device, uint32_t sample_rate, uint8_t channels, + uint8_t bits_per_sample) { + AudioStreamBasicDescription asbd = {}; + if (!asbd_for(sample_rate, channels, bits_per_sample, asbd)) { + cli_log(LogLevel::ERROR, "coreaudio: refusing stream at %u Hz / %u ch / %u-bit", + sample_rate, channels, bits_per_sample); + return false; + } + + const uint32_t max_channels = output_channels(device); + const std::string label = device_name(device); + if (max_channels == 0) { + cli_log(LogLevel::ERROR, "coreaudio: '%s' disappeared before it could be opened", + label.c_str()); + return false; + } + if (channels > max_channels) { + cli_log(LogLevel::ERROR, "coreaudio: '%s' has %u output channels, so it cannot play %u", + label.c_str(), max_channels, channels); + return false; + } + + AudioUnit unit = new_hal_unit(); + if (unit == nullptr) { + cli_log(LogLevel::ERROR, "coreaudio: this host has no AUHAL output unit to play through"); + return false; + } + // Held from here on so every failure below goes out through close_unit_(). + this->unit_ = unit; + + OSStatus err = AudioUnitSetProperty(unit, kAudioOutputUnitProperty_CurrentDevice, + kAudioUnitScope_Global, 0, &device, sizeof(device)); + if (err != noErr) { + cli_log(LogLevel::ERROR, "coreaudio: '%s' would not take the output unit: %s", + label.c_str(), os_status_text(err).c_str()); + this->close_unit_(); + return false; + } + + err = AudioUnitSetProperty(unit, kAudioUnitProperty_StreamFormat, kAudioUnitScope_Input, 0, + &asbd, sizeof(asbd)); + if (err != noErr) { + cli_log(LogLevel::ERROR, "coreaudio: '%s' would not take %u Hz / %u ch / %u-bit: %s", + label.c_str(), sample_rate, channels, bits_per_sample, os_status_text(err).c_str()); + this->close_unit_(); + return false; + } + + AURenderCallbackStruct callback = {}; + callback.inputProc = &CoreAudioSink::render_callback; + callback.inputProcRefCon = this; + err = AudioUnitSetProperty(unit, kAudioUnitProperty_SetRenderCallback, kAudioUnitScope_Input, 0, + &callback, sizeof(callback)); + if (err != noErr) { + cli_log(LogLevel::ERROR, "coreaudio: '%s' would not take a render callback: %s", + label.c_str(), os_status_text(err).c_str()); + this->close_unit_(); + return false; + } + + err = AudioUnitInitialize(unit); + if (err != noErr) { + cli_log(LogLevel::ERROR, + "coreaudio: '%s' would not initialise at %u Hz / %u ch / %u-bit: " + "%s", + label.c_str(), sample_rate, channels, bits_per_sample, os_status_text(err).c_str()); + this->close_unit_(); + return false; + } + + // Format fields and generation change before the unit starts, while the callback cannot run. + ++this->stream_generation_; + this->device_id_ = device; + this->rate_ = sample_rate; + this->channels_ = channels; + this->bits_ = bits_per_sample; + this->bytes_per_frame_ = + static_cast(channels) * (static_cast(bits_per_sample) / 8U); + this->stream_rate_ = static_cast(sample_rate); + this->ramp_step_ = volume_ramp_step(sample_rate); + this->host_ticks_per_us_ = host_ticks_per_us(); + // Open at the target gain, never ramping up to a restored volume. + this->current_multiplier_ = this->target_multiplier_.load(std::memory_order_relaxed); + + // What the unit really took on its input scope. + AudioStreamBasicDescription actual = {}; + UInt32 actual_size = sizeof(actual); + if (AudioUnitGetProperty(unit, kAudioUnitProperty_StreamFormat, kAudioUnitScope_Input, 0, + &actual, &actual_size) == noErr && + actual.mSampleRate > 0.0) { + this->stream_rate_ = actual.mSampleRate; + } + + // The render timestamp is when the hardware consumes the buffer, not when it reaches the + // speaker, so the presentation latency goes on top -- unlike PortAudio's outputBufferDacTime. + // Not the safety offset: that is the margin the HAL schedules ahead by, so it is already in + // how far in the future the timestamp sits, and adding it would count it twice. + const double device_rate = nominal_sample_rate(device); + double latency_s = 0.0; + if (device_rate > 0.0) { + const uint32_t frames = + u32_property(device, kAudioDevicePropertyLatency, kAudioDevicePropertyScopeOutput) + + first_output_stream_latency(device); + latency_s = static_cast(frames) / device_rate; + } + Float64 unit_latency_s = 0.0; + UInt32 unit_latency_size = sizeof(unit_latency_s); + if (AudioUnitGetProperty(unit, kAudioUnitProperty_Latency, kAudioUnitScope_Global, 0, + &unit_latency_s, &unit_latency_size) == noErr) { + latency_s += unit_latency_s; + } + this->output_latency_us_ = static_cast(std::llround(latency_s * 1e6)); + + const size_t capacity = this->ring_capacity_(latency_s); + this->ring_.reset(capacity); + + // Cleared before the listeners go on, so a death between the two is not lost. + this->device_lost_.store(false); + this->add_listeners_(device); + + err = AudioOutputUnitStart(unit); + if (err != noErr) { + cli_log(LogLevel::ERROR, "coreaudio: '%s' would not start: %s", label.c_str(), + os_status_text(err).c_str()); + this->close_unit_(); + return false; + } + this->running_ = true; + + this->failed_.store(false); + cli_log(LogLevel::INFO, + "coreaudio: '%s' (%s) open at %u Hz, %u ch, %u-bit (%zu bytes/frame, " + "%zu-byte ring, %.1f ms output latency)", + label.c_str(), this->name().c_str(), sample_rate, channels, bits_per_sample, + this->bytes_per_frame_, capacity, latency_s * 1000.0); + return true; +} + +void CoreAudioSink::close_unit_() { + this->remove_listeners_(); + + if (this->unit_ != nullptr) { + if (this->running_) { + AudioOutputUnitStop(this->unit_); + } + // Uninitialize waits the render callback out, so nothing below races it. + AudioUnitUninitialize(this->unit_); + AudioComponentInstanceDispose(this->unit_); + this->unit_ = nullptr; + } + + this->running_ = false; + ++this->stream_generation_; + this->device_id_ = kAudioObjectUnknown; + this->rate_ = 0; + this->channels_ = 0; + this->bits_ = 0; + this->bytes_per_frame_ = 0; + this->stream_rate_ = 0.0; + this->ramp_step_ = 0; + this->output_latency_us_ = 0; + this->ring_.reset(0); + // Wake a write() parked on the old unit. + this->space_available_.notify_all(); + // Never clear stopping_ here: a mid-stream configure() would un-latch a shutdown. +} + +bool CoreAudioSink::restart_unit_() { + if (this->running_) { + const OSStatus err = AudioOutputUnitStop(this->unit_); + if (err != noErr) { + cli_log(LogLevel::DEBUG, "coreaudio: cannot stop the unit: %s", + os_status_text(err).c_str()); + return false; + } + this->running_ = false; + } + + // A new stream: a write() parked on the old one holds dropped audio. + ++this->stream_generation_; + + // The callback has stopped, so dropping the ring from this side is safe here. + this->ring_.drop(); + // Snap the gain too, so the next stream starts at its target. + this->current_multiplier_ = this->target_multiplier_.load(std::memory_order_relaxed); + + const OSStatus err = AudioOutputUnitStart(this->unit_); + if (err != noErr) { + cli_log(LogLevel::DEBUG, "coreaudio: cannot restart the unit: %s", + os_status_text(err).c_str()); + return false; + } + this->running_ = true; + + this->failed_.store(false); + return true; +} + +bool CoreAudioSink::reopen_in_place_() { + if (this->unit_ == nullptr || this->stopping_.load() || this->last_format_.sample_rate == 0) { + // Only a unit that ran and died: a refused format has a null unit_ and needs no retry. + return false; + } + if (!this->recovery_.reopen_due()) { + return false; + } + + // Resolve before closing, so a failure leaves write() its frame size. + AudioDeviceID device = kAudioObjectUnknown; + std::string error; + if (!resolve_ca_device(this->device_, device, error)) { + cli_log(LogLevel::WARN, "coreaudio: cannot reopen '%s': %s", this->name().c_str(), + error.c_str()); + this->recovery_.reopen_done(false); + return false; + } + + const StreamFormat format = this->last_format_; + this->discard_ring_tail_(); + this->close_unit_(); + if (!this->open_unit_(device, format.sample_rate, format.channels, format.bit_depth)) { + this->recovery_.reopen_done(false); // open_unit_() has already said why, once + return false; + } + this->recovery_.reopen_done(true); + + if (this->stopping_.load()) { + // stop() can land during the open above; release the device now. + this->close_unit_(); + return false; + } + + cli_log(LogLevel::INFO, + "coreaudio: '%s' recovered on '%s' without waiting for the next " + "stream", + this->name().c_str(), device_name(device).c_str()); + return true; +} + +void CoreAudioSink::discard_ring_tail_() { + if (this->bytes_per_frame_ == 0) { + return; // no unit, so close_unit_() has already emptied the ring + } + this->recovery_.discard_frames( + static_cast(this->ring_.available() / this->bytes_per_frame_)); +} + +bool CoreAudioSink::callback_running_() const { + return this->unit_ != nullptr && this->running_; +} + +bool CoreAudioSink::unit_alive_() const { + return this->callback_running_() && !this->device_lost_.load(); +} + +size_t CoreAudioSink::ring_capacity_(double device_latency_s) const { + const auto frames_by_time = + static_cast((static_cast(this->rate_) * this->buffer_ms_) / 1000); + const auto frames_by_latency = + static_cast(device_latency_s * this->stream_rate_ * RING_LATENCY_MULTIPLE); + const size_t frames = std::max({frames_by_time, frames_by_latency, MIN_RING_FRAMES}); + // Log which floor overrode --buffer-ms. + if (frames > frames_by_time) { + cli_log(LogLevel::DEBUG, + "coreaudio: --buffer-ms %u is %zu frames at %u Hz, below the %s floor of " + "%zu frames -- using the floor", + this->buffer_ms_, frames_by_time, this->rate_, + (frames_by_latency >= MIN_RING_FRAMES) ? "device-latency" : "minimum-ring", frames); + } + // The spare byte the ring keeps to tell full from empty, so `frames` really do fit. + return (frames * this->bytes_per_frame_) + 1; +} + +void CoreAudioSink::update_target_multiplier_() { + // Only the target moves; the callback advances current_multiplier_. + this->target_multiplier_.store(q32_gain_for(this->volume_.load(), this->muted_.load()), + std::memory_order_relaxed); +} + +void CoreAudioSink::add_listeners_(AudioDeviceID device) { + this->remove_listeners_(); + + const AudioObjectPropertyAddress alive = address_of(kAudioDevicePropertyDeviceIsAlive); + if (AudioObjectAddPropertyListener(device, &alive, &CoreAudioSink::property_listener, this) == + noErr) { + this->listening_alive_ = true; + this->listening_device_ = device; + } + + if (this->device_.empty()) { + const AudioObjectPropertyAddress fallback = + address_of(kAudioHardwarePropertyDefaultOutputDevice); + if (AudioObjectAddPropertyListener(kAudioObjectSystemObject, &fallback, + &CoreAudioSink::property_listener, this) == noErr) { + this->listening_default_ = true; + } + } +} + +void CoreAudioSink::remove_listeners_() { + if (this->listening_alive_) { + const AudioObjectPropertyAddress alive = address_of(kAudioDevicePropertyDeviceIsAlive); + AudioObjectRemovePropertyListener(this->listening_device_, &alive, + &CoreAudioSink::property_listener, this); + this->listening_alive_ = false; + this->listening_device_ = kAudioObjectUnknown; + } + if (this->listening_default_) { + const AudioObjectPropertyAddress fallback = + address_of(kAudioHardwarePropertyDefaultOutputDevice); + AudioObjectRemovePropertyListener(kAudioObjectSystemObject, &fallback, + &CoreAudioSink::property_listener, this); + this->listening_default_ = false; + } +} + +OSStatus CoreAudioSink::property_listener(AudioObjectID /*object*/, UInt32 count, + const AudioObjectPropertyAddress* addresses, + void* user_data) { + auto* self = static_cast(user_data); + for (UInt32 i = 0; i < count; ++i) { + switch (addresses[i].mSelector) { + case kAudioDevicePropertyDeviceIsAlive: + // Only flagged: this runs on a HAL thread that must not block on mutex_. + self->device_lost_.store(true); + self->space_available_.notify_all(); + break; + case kAudioHardwarePropertyDefaultOutputDevice: + self->default_moved_.store(true); + break; + default: + break; + } + } + return noErr; +} + +OSStatus CoreAudioSink::render_callback(void* user_data, AudioUnitRenderActionFlags* /*flags*/, + const AudioTimeStamp* timestamp, UInt32 /*bus*/, + UInt32 frames, AudioBufferList* data) { + const int64_t entered_us = now_us(); + const uint64_t entered_host = mach_absolute_time(); + + auto* self = static_cast(user_data); + if (data == nullptr || data->mNumberBuffers == 0 || self->bytes_per_frame_ == 0) { + return noErr; + } + auto* out = static_cast(data->mBuffers[0].mData); + if (out == nullptr) { + return noErr; + } + + // Interleaved, so one buffer carries every channel; the AU's own size is the hard limit. + const size_t bytes_requested = std::min(static_cast(frames) * self->bytes_per_frame_, + static_cast(data->mBuffers[0].mDataByteSize)); + + const size_t bytes_read = self->ring_.read(out, bytes_requested); + + // The unity fast path also requires current == target, or it would skip a ramp. + const uint64_t target = self->target_multiplier_.load(std::memory_order_relaxed); + const uint64_t current = self->current_multiplier_; + if (current != target || target != Q32_ONE) { + // Advanced by every frame handed over, zero-filled tail included: silence is played too. + self->current_multiplier_ = + apply_volume_ramp(out, bytes_requested, self->bits_ / 8U, self->channels_, current, + target, self->ramp_step_); + } + + // Notified without the mutex, keeping the callback lock-free; a missed wakeup is bounded. + self->space_available_.notify_one(); + + if (bytes_read > 0 && self->on_frames_played) { + const auto frames_played = static_cast(bytes_read / self->bytes_per_frame_); + int64_t ahead_us = 0; + if (self->host_ticks_per_us_ > 0.0 && + (timestamp->mFlags & kAudioTimeStampHostTimeValid) != 0) { + // Signed on purpose: an already-past timestamp must subtract, not wrap. + const auto ticks = static_cast(timestamp->mHostTime - entered_host); + ahead_us = static_cast( + std::llround(static_cast(ticks) / self->host_ticks_per_us_)); + } + const auto buffer_us = static_cast( + std::llround((static_cast(frames_played) / self->stream_rate_) * 1e6)); + const int64_t finish_us = entered_us + ahead_us + self->output_latency_us_ + buffer_us; + // The gap rides on this report rather than its own: the player sums the frames of reports + // it has not read yet but keeps only the last timestamp. + self->on_frames_played(frames_with_gap(self->gap_handoff_.take(), frames_played), + finish_us); + } + + return noErr; +} + +} // namespace sendspin_cli diff --git a/src/coreaudio_sink.h b/src/coreaudio_sink.h new file mode 100644 index 0000000..f5c3e7c --- /dev/null +++ b/src/coreaudio_sink.h @@ -0,0 +1,178 @@ +// Copyright 2026 sendspin-cpp-cli Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// AudioSink over CoreAudio's AUHAL output unit, with render-callback DAC-time sync feedback. + +#pragma once + +#include "audio_sink.h" +#include "pcm_ring.h" +#include "pcm_volume.h" +#include "sink_recovery.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace sendspin_cli { + +/// An AudioSink that plays through CoreAudio: `-o coreaudio:` an index, a device name, or empty. +/// The render callback reads format fields unlocked: change them only while no unit is running. +class CoreAudioSink final : public AudioSink { +public: + /// @param buffer_ms Ring size request; the device-latency and minimum-ring floors win. + CoreAudioSink(std::string device, uint32_t buffer_ms); + ~CoreAudioSink() override; + + std::string name() const override; + bool configure(uint32_t sample_rate, uint8_t channels, uint8_t bits_per_sample) override; + size_t write(const uint8_t* data, size_t length, uint32_t timeout_ms) override; + void clear() override; + void stop() override; + void set_volume(uint8_t volume) override; + void set_muted(bool muted) override; + + /// Follows a moved system default, and retries the reopen once a dead device has + /// escalated; blocks while either runs. + void poll(int64_t now_ms) override; + + /// What the resolved device's AUHAL unit will take; opens a scratch unit to ask. + SinkCapabilities capabilities() const override; + + /// Checks that `device` names exactly one output device on this host; no format is tested. + static bool probe(const std::string& device, std::string& error); + + /// Prints this host's CoreAudio output devices. + static void list_devices(std::FILE* out); + +private: + static OSStatus render_callback(void* user_data, AudioUnitRenderActionFlags* flags, + const AudioTimeStamp* timestamp, UInt32 bus, UInt32 frames, + AudioBufferList* data); + + /// Property listener for device death and default-output changes; runs off a HAL thread. + /// Only ever flags work for poll() and write(): it must not take mutex_. + static OSStatus property_listener(AudioObjectID object, UInt32 count, + const AudioObjectPropertyAddress* addresses, void* user_data); + + /// Opens, initializes and starts an AUHAL unit. Caller holds mutex_ and has closed any + /// previous unit. + bool open_unit_(AudioDeviceID device, uint32_t sample_rate, uint8_t channels, + uint8_t bits_per_sample); + /// Stops, uninitializes and disposes the unit and forgets the format. Caller holds mutex_. + /// Must not touch stopping_, or a format change would un-latch a shutdown. + void close_unit_(); + /// Restarts the open unit from an empty ring. Caller holds mutex_; unit_ is not null. + /// @return true if the unit is running again. + bool restart_unit_(); + /// The one in-place reopen a dead unit gets, from write(). Caller holds mutex_. + /// @return true if a unit is running again. + bool reopen_in_place_(); + /// Reopens on the system default's new device, for a bare -o coreaudio. Not a recovery: a + /// move is an ordinary event, so it must not spend the budget an outage needs. + void follow_default_(); + /// Adds the frames still in the ring to the outage gap: the player has counted them, and the + /// lost unit recovery is about to close will never report them. Only for those closes -- + /// stop(), configure() and clear() end the stream the gap belonged to. Caller holds mutex_. + void discard_ring_tail_(); + /// True while the render callback is still being driven. Caller holds mutex_. + /// Liveness only: a lost device does not stop the callback, so this stays true until the + /// unit does. Anything touching the ring's consumer side must ask this, not unit_alive_(). + bool callback_running_() const; + /// True while the open unit is still worth feeding: running, on a device that has not died. + /// Caller holds mutex_. + bool unit_alive_() const; + /// Ring size in bytes. Caller holds mutex_ and the format fields are set. + size_t ring_capacity_(double device_latency_s) const; + void update_target_multiplier_(); + + /// Starts listening for the open device's death, and for default-output moves when following + /// the default. Caller holds mutex_; listeners are removed by close_unit_(). + /// Removal does not wait an in-flight listener out, unlike PortAudio's stream close, so a + /// notification can still land on the atomics just after the sink is destroyed. + void add_listeners_(AudioDeviceID device); + void remove_listeners_(); + + /// The device as -o spelled it, resolved per stream. + std::string device_; + /// Ring size to aim for, in milliseconds, before the floors in ring_capacity_() apply. + uint32_t buffer_ms_; + + /// Serialises unit_, the format fields, and the ring buffer's producer side. + std::mutex mutex_; + /// Signalled by the render callback once it has drained a buffer's worth of the ring. + std::condition_variable space_available_; + PcmRingBuffer ring_; + AudioUnit unit_{nullptr}; + /// True between AudioOutputUnitStart() and the matching stop. Guarded by mutex_. + bool running_{false}; + AudioDeviceID device_id_{kAudioObjectUnknown}; + uint32_t rate_{0}; + uint8_t channels_{0}; + uint8_t bits_{0}; + /// Read by the render callback unlocked; see the class comment. + size_t bytes_per_frame_{0}; + /// The rate the unit's input scope really took. Read by the render callback. + double stream_rate_{0.0}; + /// Per-frame gain increment from volume_ramp_step(); 0 means no ramp. Read by the callback. + uint64_t ramp_step_{0}; + /// Mach ticks per microsecond, from mach_timebase_info(). Read by the render callback. + double host_ticks_per_us_{0.0}; + /// Output path latency the render timestamp does not carry, in microseconds. Read by the + /// render callback. + int64_t output_latency_us_{0}; + + /// Bumped on open, close, restart or flush; a write() that waited compares it on waking. + uint64_t stream_generation_{0}; + + /// Set by the property listener when the open device dies. Latches until the next open; + /// read unlocked by write(). + std::atomic device_lost_{false}; + /// Set by the property listener when the system default output moves; poll() clears it. + std::atomic default_moved_{false}; + /// Whether each listener is registered, so each is removed exactly once. Guarded by mutex_. + bool listening_alive_{false}; + bool listening_default_{false}; + /// The device the death listener is registered on. Guarded by mutex_. + AudioDeviceID listening_device_{kAudioObjectUnknown}; + + /// Set before stop() takes the mutex so a blocked write() bails out; latches. + std::atomic stopping_{false}; + /// Latches so write() complains once about a missing unit; cleared by a good configure(). + std::atomic failed_{false}; + + /// Format last asked for, even if its open failed; recovery reopens at it. Guarded by mutex_. + StreamFormat last_format_{}; + /// Guarded by mutex_, except SinkRecovery::pending(). + SinkRecovery recovery_; + /// The outage gap once write() has handed it on, for the callback to retire lock-free with + /// its next report. recovery_ cannot be read there; see OutageGapHandoff. + OutageGapHandoff gap_handoff_; + + std::atomic volume_{DEFAULT_SINK_VOLUME}; + std::atomic muted_{false}; + /// Q32 gain the ramp is heading for; written by the main loop, read by the callback. + std::atomic target_multiplier_{Q32_ONE}; + /// Q32 gain being applied; only the callback advances it. clear() must not touch it. + std::uint64_t current_multiplier_{Q32_ONE}; +}; + +} // namespace sendspin_cli diff --git a/tests/device_spec_test.cpp b/tests/device_spec_test.cpp index 0c7db7b..9c79698 100644 --- a/tests/device_spec_test.cpp +++ b/tests/device_spec_test.cpp @@ -79,6 +79,11 @@ TEST(ResolveDeviceSpec, BackendListMatchesTheBuild) { #else EXPECT_EQ(audio_backend_list().find("alsa"), std::string::npos); #endif +#ifdef SENDSPIN_CLI_HAVE_COREAUDIO + EXPECT_NE(audio_backend_list().find("coreaudio"), std::string::npos); +#else + EXPECT_EQ(audio_backend_list().find("coreaudio"), std::string::npos); +#endif #ifdef SENDSPIN_CLI_HAVE_PORTAUDIO EXPECT_NE(audio_backend_list().find("portaudio"), std::string::npos); #else @@ -143,6 +148,8 @@ TEST(ResolveDeviceSpec, ShadowedPcmNamesAreOnlyTheBackendNames) { } EXPECT_FALSE(alsa_pcm_is_reachable("null")); EXPECT_FALSE(alsa_pcm_is_reachable("alsa")); + // Reserved even here, where the backend cannot exist: see the CoreAudio block below. + EXPECT_FALSE(alsa_pcm_is_reachable("coreaudio")); #ifdef SENDSPIN_CLI_HAVE_PULSE EXPECT_FALSE(alsa_pcm_is_reachable("pulse")); @@ -171,14 +178,63 @@ TEST(ResolveDeviceSpec, BarePcmNamesHaveNowhereToGo) { const std::string error = rejected("hw:2,0"); EXPECT_NE(error.find("unknown output device"), std::string::npos); EXPECT_NE(error.find(audio_backend_list()), std::string::npos); -#ifdef SENDSPIN_CLI_HAVE_PORTAUDIO // Names the prefix, since a bare device name is the likely mistake. +#ifdef SENDSPIN_CLI_HAVE_COREAUDIO + EXPECT_NE(error.find("-o coreaudio:hw:2,0"), std::string::npos); +#endif +#ifdef SENDSPIN_CLI_HAVE_PORTAUDIO EXPECT_NE(error.find("-o portaudio:hw:2,0"), std::string::npos); #endif } #endif // SENDSPIN_CLI_HAVE_ALSA +// The CoreAudio prefix, whose device is optional + +#ifdef SENDSPIN_CLI_HAVE_COREAUDIO + +TEST(ResolveDeviceSpec, BareCoreaudioMeansThisHostsDefaultOutput) { + // An empty device follows the host's default output. + const DeviceSpec spec = resolved("coreaudio"); + EXPECT_EQ(spec.backend, SinkBackend::CoreAudio); + EXPECT_TRUE(spec.device.empty()); +} + +TEST(ResolveDeviceSpec, CoreaudioTakesAnIndexOrAName) { + EXPECT_EQ(resolved("coreaudio:2").backend, SinkBackend::CoreAudio); + EXPECT_EQ(resolved("coreaudio:2").device, "2"); + + EXPECT_EQ(resolved("coreaudio:MacBook Pro Speakers").device, "MacBook Pro Speakers"); + EXPECT_EQ(resolved("coreaudio:External Headphones").device, "External Headphones"); + // Split on the first colon only, like every other prefix. + EXPECT_EQ(resolved("coreaudio:BlackHole 2ch: Aggregate").device, "BlackHole 2ch: Aggregate"); +} + +TEST(ResolveDeviceSpec, CoreaudioPrefixWithNothingAfterTheColonIsRejected) { + // An empty device after the colon is a truncated command line. + const std::string error = rejected("coreaudio:"); + EXPECT_NE(error.find("no device"), std::string::npos); + EXPECT_NE(error.find("-o coreaudio on its own"), std::string::npos) + << "the message should point at the form that does mean the default"; +} + +#else // no CoreAudio backend in this build + +TEST(ResolveDeviceSpec, CoreaudioSaysItIsNotInThisBuild) { + // Reserved, so a Linux build explains it instead of handing it to ALSA as a PCM name. + for (const char* spec : {"coreaudio", "coreaudio:2", "coreaudio:MacBook Pro Speakers"}) { + const std::string error = rejected(spec); + EXPECT_NE(error.find("CoreAudio backend"), std::string::npos) << spec; + EXPECT_NE(error.find("not in this build"), std::string::npos) << spec; + EXPECT_NE(error.find("macOS-only"), std::string::npos) << spec; + EXPECT_NE(error.find("SENDSPIN_CLI_WITH_COREAUDIO"), std::string::npos) << spec; + EXPECT_NE(error.find(audio_backend_list()), std::string::npos) + << spec << ": the error should name the backends this build has"; + } +} + +#endif // SENDSPIN_CLI_HAVE_COREAUDIO + // The PortAudio prefix, whose device is optional #ifdef SENDSPIN_CLI_HAVE_PORTAUDIO