From d3d8c1e230172cc5a0d55d5257a0a89656389ac5 Mon Sep 17 00:00:00 2001 From: Chris Uthe Date: Thu, 15 Oct 2026 13:38:22 -0500 Subject: [PATCH 1/5] Add a loopback input on macOS through a process tap --input loopback captures everything the Mac plays, and loopback: one output device, through a CoreAudio process tap read by the existing AUHAL capture path via a private aggregate device. Both taps leave out sendspin-cli's own playback. macOS 14.2 or later is required at runtime, and older SDKs still build without it. A refused System Audio Recording permission yields silence, so the start-up log says where to grant it and a tap that delivers only silence logs one warning. --- CMakeLists.txt | 6 +- README.md | 2 +- docs/wiki/Advanced-Usage.md | 18 ++++ docs/wiki/Configuration.md | 2 +- packaging/sendspin-cli.Info.plist.in | 2 + src/audio_source.cpp | 35 ++++-- src/audio_source.h | 6 ++ src/cli.cpp | 2 +- src/coreaudio_device.cpp | 5 +- src/coreaudio_device.h | 4 +- src/coreaudio_source.cpp | 125 ++++++++++++++++++---- src/coreaudio_source.h | 26 ++++- src/coreaudio_tap.h | 58 ++++++++++ src/coreaudio_tap.mm | 152 +++++++++++++++++++++++++++ tests/audio_source_test.cpp | 6 +- 15 files changed, 411 insertions(+), 38 deletions(-) create mode 100644 src/coreaudio_tap.h create mode 100644 src/coreaudio_tap.mm diff --git a/CMakeLists.txt b/CMakeLists.txt index a61d21c..3b879db 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -7,7 +7,8 @@ project(sendspin-cpp-cli LANGUAGES CXX ) -# The CoreAudio capture source asks for microphone access through AVFoundation, which is Objective-C. +# The CoreAudio capture source asks for the microphone through AVFoundation and taps output +# through CATapDescription, both Objective-C. if(APPLE) enable_language(OBJCXX) endif() @@ -301,9 +302,10 @@ if(SENDSPIN_CLI_COREAUDIO_ENABLED) src/coreaudio_permission.mm src/coreaudio_sink.cpp src/coreaudio_source.cpp + src/coreaudio_tap.mm ) # -Wno-error: blocks and Apple's headers are outside what -Wpedantic was chosen for. - set_source_files_properties(src/coreaudio_permission.mm PROPERTIES + set_source_files_properties(src/coreaudio_permission.mm src/coreaudio_tap.mm PROPERTIES COMPILE_OPTIONS "-fobjc-arc;-Wno-error") target_link_libraries(sendspin-cli-core PUBLIC "-framework CoreAudio" "-framework AudioToolbox" "-framework CoreFoundation" diff --git a/README.md b/README.md index b61289d..28c89be 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ sendspin-cli -n den --input default | `-n, --name ` | The friendly name a server displays. Defaults to this host's name. | | `-o, --output ` | Which sound card to play through. `-l` lists this host's devices and what they accept. | | `--audio-format [,...]` | Formats to offer first, in priority order, e.g. `flac:48000:24:2,pcm:48000:24:2`. Everything else the player normally offers still follows, and a server uses the first format it can encode, so it can still fall back to a later one. This sets a preference, not a restriction. | -| `--input ` | Audio to stream to the server: a microphone or line-in, e.g. `default`, `hw:1,0`, `pulse` or `pipewire:`, or `coreaudio` on macOS; or `loopback` for what this host plays. `-l` lists them. Off unless given. See [Stream a microphone or line-in](https://github.com/Sendspin/sendspin-cpp-cli/wiki/Advanced-Usage#stream-a-microphone-or-line-in) and [Stream what this host plays](https://github.com/Sendspin/sendspin-cpp-cli/wiki/Advanced-Usage#stream-what-this-host-plays). | +| `--input ` | Audio to stream to the server: a microphone or line-in, e.g. `default`, `hw:1,0`, `pulse` or `pipewire:`, or `coreaudio` on macOS; or `loopback` for what this host plays (on macOS without the player's own playback, on Linux with it). `-l` lists them. Off unless given. See [Stream a microphone or line-in](https://github.com/Sendspin/sendspin-cpp-cli/wiki/Advanced-Usage#stream-a-microphone-or-line-in) and [Stream what this host plays](https://github.com/Sendspin/sendspin-cpp-cli/wiki/Advanced-Usage#stream-what-this-host-plays). | | `--allow-unpaired` | Let a server play without pairing with this player first. Off by default. | | `--pairing-code <8 digits>` | A fixed code a server can pair with, confirmed with `sendspin-cli pair confirm`. Without it the player shows a fresh 6-digit code per attempt. | | `-s, --server ` | Connect out to a server rather than waiting to be discovered, or `mdns:[]` to discover one over mDNS. Turns off the mDNS advertisement. | diff --git a/docs/wiki/Advanced-Usage.md b/docs/wiki/Advanced-Usage.md index d50058a..a0c874e 100644 --- a/docs/wiki/Advanced-Usage.md +++ b/docs/wiki/Advanced-Usage.md @@ -137,6 +137,24 @@ so the server gets its own audio back. The player logs a warning at startup unle names a different PulseAudio or PipeWire sink, or is `null` or `stdout`. Loop back a sink the player does not play to. +On macOS 14.2 or later, `loopback` captures everything the Mac plays through a CoreAudio +process tap, and `loopback:` captures one output device, numbered and named as +`-o coreaudio:` names it. Both leave out sendspin-cli's own playback, so a player on the +same Mac is never sent back to the server. + +```bash +sendspin-cli -n den --input loopback +sendspin-cli -n den --input "loopback:MacBook Pro Speakers" +``` + +macOS asks once for **System Audio Recording**. It names the app sendspin-cli is started +from: the terminal, or `sendspin-cli` itself under `launchd`. If the answer is no, macOS +gives the tap silence rather than an error, so the player keeps running, and logs a +warning after ten seconds of nothing but silence. Allow it under **System Settings → +Privacy & Security → Screen & System Audio Recording → System Audio Recording Only**. +macOS does not ask again by itself after a refusal; `tccutil reset AudioCapture` makes it +ask on the next start. + ### Microphone permission on macOS macOS asks for microphone access the first time `--input` opens a CoreAudio device, and diff --git a/docs/wiki/Configuration.md b/docs/wiki/Configuration.md index 359717b..faf1833 100644 --- a/docs/wiki/Configuration.md +++ b/docs/wiki/Configuration.md @@ -53,7 +53,7 @@ already does that. | `product-name` | `--product-name` | the product name `client/hello` reports | `sendspin-cli` | | `server` | `-s`, `--server` | `[:]`, a `ws://` URL, or `mdns:[]` | none — wait to be discovered | | `port` | `--port` | the port this player's own WebSocket server listens on | `8928` | -| `input` | `--input` | a capture device to stream to the server: `default`, `hw:1,0`, `alsa:`, `pulse[:]`, `pipewire[:]`, `coreaudio[:]` on macOS, `loopback[:]`, `tone`, `null`; see [Stream a microphone or line-in](Advanced-Usage#stream-a-microphone-or-line-in) and [Stream what this host plays](Advanced-Usage#stream-what-this-host-plays) | none — nothing is captured | +| `input` | `--input` | a capture device to stream to the server: `default`, `hw:1,0`, `alsa:`, `pulse[:]`, `pipewire[:]`, `coreaudio[:]` on macOS, `loopback[:]` (on macOS `loopback[:]`), `tone`, `null`; see [Stream a microphone or line-in](Advanced-Usage#stream-a-microphone-or-line-in) and [Stream what this host plays](Advanced-Usage#stream-what-this-host-plays) | none — nothing is captured | | `buffer-ms` | `--buffer-ms` | audio the output backend keeps queued, 10–2000 | `100` | | `audio-format` | `--audio-format` | preferred formats, comma-separated in priority order: `codec:rate:depth:channels[,...]`, e.g. `flac:48000:24:2,pcm:48000:24:2`; offered first in that order, with the rest of the advertised list still behind them, so a server that cannot encode them falls back — a preference, not a restriction; refuses to start if the advertised list does not carry every one — it carries a single channel count — and an `opus` entry at anything but 48000/16 and at most 2 channels is refused outright | none — device-derived order | | `output-delay` | `--output-delay` | latency this endpoint's hardware adds after the audio port, 0–5000. `static-delay` / `--static-delay` is still accepted as the old name | `0` | diff --git a/packaging/sendspin-cli.Info.plist.in b/packaging/sendspin-cli.Info.plist.in index 4bb6078..59734c6 100644 --- a/packaging/sendspin-cli.Info.plist.in +++ b/packaging/sendspin-cli.Info.plist.in @@ -14,5 +14,7 @@ @PROJECT_VERSION@ NSMicrophoneUsageDescription sendspin-cli streams this microphone or line-in to your Sendspin server when run with --input. + NSAudioCaptureUsageDescription + sendspin-cli streams what this Mac plays to your Sendspin server when run with --input loopback. diff --git a/src/audio_source.cpp b/src/audio_source.cpp index 8191000..c0ca7f5 100644 --- a/src/audio_source.cpp +++ b/src/audio_source.cpp @@ -66,10 +66,14 @@ constexpr bool HAVE_PIPEWIRE = true; #else constexpr bool HAVE_PIPEWIRE = false; #endif -constexpr bool HAVE_LOOPBACK = HAVE_PULSE || HAVE_PIPEWIRE; +#ifdef SENDSPIN_CLI_HAVE_LOOPBACK +constexpr bool HAVE_LOOPBACK = true; +#else +constexpr bool HAVE_LOOPBACK = false; +#endif -#if defined(SENDSPIN_CLI_HAVE_PULSE) || defined(SENDSPIN_CLI_HAVE_PIPEWIRE) -/// The loopback source on the first sound server that answers: PipeWire, then PulseAudio. +#ifdef SENDSPIN_CLI_HAVE_LOOPBACK +/// The loopback source on the first backend that can capture: PipeWire, PulseAudio, CoreAudio. std::unique_ptr make_loopback_source(const std::string& device, std::string& error) { std::string tried; #ifdef SENDSPIN_CLI_HAVE_PIPEWIRE @@ -86,7 +90,14 @@ std::unique_ptr make_loopback_source(const std::string& device, std } tried += (tried.empty() ? "" : "; ") + ("PulseAudio: " + pulse_error); #endif - error = "--input loopback: no sound server answered (" + tried + ")"; +#ifdef SENDSPIN_CLI_HAVE_COREAUDIO + std::string coreaudio_error; + if (CoreAudioTap::supported(coreaudio_error)) { + return std::make_unique(device, true); + } + tried += (tried.empty() ? "" : "; ") + ("CoreAudio: " + coreaudio_error); +#endif + error = "--input loopback found nothing to capture through (" + tried + ")"; return nullptr; } #endif @@ -305,8 +316,8 @@ bool resolve_input_spec(const std::string& spec, InputSpec& out, std::string& er if (prefix == "loopback") { if (!HAVE_LOOPBACK) { - error = "--input loopback needs the PipeWire or PulseAudio backend, and this build has " - "neither, so --input takes only: " + + error = "--input loopback needs the PipeWire, PulseAudio or CoreAudio backend, and " + "this build has none of them, so --input takes only: " + input_backend_list(); return false; } @@ -401,7 +412,7 @@ std::unique_ptr make_audio_source(const std::string& spec, StreamFo #endif break; case SourceBackend::Loopback: -#if defined(SENDSPIN_CLI_HAVE_PULSE) || defined(SENDSPIN_CLI_HAVE_PIPEWIRE) +#ifdef SENDSPIN_CLI_HAVE_LOOPBACK source = make_loopback_source(resolved.device, error); if (!source) { return nullptr; @@ -490,6 +501,16 @@ void print_capture_devices(std::FILE* out) { "16-bit. Audio MIDI Setup changes the rate. macOS asks for microphone access\n" "the first time --input opens a device.\n"); #endif +#ifdef SENDSPIN_CLI_HAVE_COREAUDIO + std::fprintf(out, + "\nLoopback (--input loopback[:]):\n" + "--input loopback captures everything this Mac plays, and --input\n" + "loopback: one output device, through a process tap (macOS 14.2 or\n" + "later). sendspin-cli's own playback is left out. macOS asks once for System\n" + "Audio Recording; refused, the capture is silent.\n" + "\nCoreAudio output devices on this host:\n"); + CoreAudioSource::list_loopback_devices(out); +#endif #if !defined(SENDSPIN_CLI_HAVE_ALSA) && !defined(SENDSPIN_CLI_HAVE_PULSE) && \ !defined(SENDSPIN_CLI_HAVE_PIPEWIRE) && !defined(SENDSPIN_CLI_HAVE_COREAUDIO) std::fprintf(out, "\nThis build has no ALSA, PulseAudio, PipeWire or CoreAudio backend, so\n" diff --git a/src/audio_source.h b/src/audio_source.h index a99355b..cb939bc 100644 --- a/src/audio_source.h +++ b/src/audio_source.h @@ -32,6 +32,12 @@ #include #include +#if defined(SENDSPIN_CLI_HAVE_PULSE) || defined(SENDSPIN_CLI_HAVE_PIPEWIRE) || \ + defined(SENDSPIN_CLI_HAVE_COREAUDIO) +/// Some backend in this build can capture what the host plays. +#define SENDSPIN_CLI_HAVE_LOOPBACK +#endif + namespace sendspin_cli { /// Bit depths the source role takes, in the order a capture device is asked for them. diff --git a/src/cli.cpp b/src/cli.cpp index 81a171a..8f43e33 100644 --- a/src/cli.cpp +++ b/src/cli.cpp @@ -952,7 +952,7 @@ void print_usage(std::FILE* out, const char* prog) { std::fprintf(out, " Capture device to stream to the server: a microphone or\n"); std::fprintf(out, " line-in. Read the way -o is, with one of: %s\n", input_backend_list().c_str()); -#if defined(SENDSPIN_CLI_HAVE_PULSE) || defined(SENDSPIN_CLI_HAVE_PIPEWIRE) +#ifdef SENDSPIN_CLI_HAVE_LOOPBACK std::fprintf(out, " --input loopback streams what this host plays, and\n"); std::fprintf(out, " --input loopback: what one sink plays\n"); #endif diff --git a/src/coreaudio_device.cpp b/src/coreaudio_device.cpp index 224ee7d..d170d2e 100644 --- a/src/coreaudio_device.cpp +++ b/src/coreaudio_device.cpp @@ -204,7 +204,7 @@ std::vector ca_enumerate_devices(CaDirection direction) { } bool resolve_ca_device(const std::string& device, CaDirection direction, AudioDeviceID& out, - std::string& error) { + std::string& error, const std::string& typed) { const std::string noun = noun_of(direction); if (device.empty()) { const AudioDeviceID fallback = ca_default_device(direction); @@ -223,7 +223,8 @@ bool resolve_ca_device(const std::string& device, CaDirection direction, AudioDe return false; } - const std::string spec = std::string(flag_of(direction)) + " coreaudio:" + device; + const std::string spec = + typed.empty() ? std::string(flag_of(direction)) + " coreaudio:" + device : typed; 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); diff --git a/src/coreaudio_device.h b/src/coreaudio_device.h index 48421e4..269e82c 100644 --- a/src/coreaudio_device.h +++ b/src/coreaudio_device.h @@ -59,8 +59,10 @@ AudioDeviceID ca_default_device(CaDirection direction); std::vector ca_enumerate_devices(CaDirection direction); /// Resolves a CoreAudio device spec: empty is the default, digits an index, else a unique name. +/// @param typed The spec as the user wrote it, for error text; empty for ` +/// coreaudio:`. bool resolve_ca_device(const std::string& device, CaDirection direction, AudioDeviceID& out, - std::string& error); + std::string& error, const std::string& typed = {}); /// Maps a stream format onto the interleaved signed little-endian PCM an AUHAL unit takes. bool ca_asbd_for(uint32_t sample_rate, uint8_t channels, uint8_t bits_per_sample, diff --git a/src/coreaudio_source.cpp b/src/coreaudio_source.cpp index 9c0e8a9..876d030 100644 --- a/src/coreaudio_source.cpp +++ b/src/coreaudio_source.cpp @@ -42,6 +42,9 @@ constexpr uint32_t MIN_RENDER_FRAMES = 4096; constexpr int64_t US_PER_S = 1000000; /// AUHAL's elements: 1 faces the device's input side, 0 its output side. +/// How long a tap may deliver only digital silence before the permission hint is logged. +constexpr uint64_t TAP_SILENCE_WARN_S = 10; + constexpr UInt32 INPUT_BUS = 1; constexpr UInt32 OUTPUT_BUS = 0; @@ -91,14 +94,63 @@ OSStatus set_capture_format(AudioUnit unit, const AudioStreamBasicDescription& a } // namespace -CoreAudioSource::CoreAudioSource(std::string device) : device_(std::move(device)) {} +CoreAudioSource::CoreAudioSource(std::string device, bool loopback) + : device_(std::move(device)), loopback_(loopback) {} CoreAudioSource::~CoreAudioSource() { this->close(); } std::string CoreAudioSource::name() const { - return this->device_.empty() ? "coreaudio" : "coreaudio:" + this->device_; + const char* prefix = this->loopback_ ? "loopback" : "coreaudio"; + return this->device_.empty() ? prefix : prefix + (":" + this->device_); +} + +void CoreAudioSource::list_loopback_devices(std::FILE* out) { + const std::vector outputs = ca_enumerate_devices(CaDirection::Output); + if (outputs.empty()) { + std::fprintf(out, " (this host has no CoreAudio output devices)\n"); + return; + } + const AudioDeviceID fallback = ca_default_device(CaDirection::Output); + for (size_t i = 0; i < outputs.size(); ++i) { + std::fprintf(out, " %3zu %s%s\n", i, ca_device_name(outputs[i]).c_str(), + (outputs[i] == fallback) ? " (system default)" : ""); + } +} + +bool CoreAudioSource::resolve_device_(AudioDeviceID& out, std::string& error) { + if (!this->loopback_) { + return resolve_ca_device(this->device_, CaDirection::Input, out, error); + } + this->tapped_output_ = kAudioObjectUnknown; + if (!this->device_.empty() && + !resolve_ca_device(this->device_, CaDirection::Output, this->tapped_output_, error, + "--input " + this->name())) { + return false; + } + if (!this->tap_.create(this->tapped_output_, error)) { + return false; + } + out = this->tap_.device(); + return true; +} + +void CoreAudioSource::check_tap_silence_(const uint8_t* data, size_t bytes) { + if (std::any_of(data, data + bytes, [](uint8_t byte) { return byte != 0; })) { + this->silent_frames_ = 0; + return; + } + this->silent_frames_ += bytes / this->bytes_per_frame_; + if (!this->silence_warned_ && + this->silent_frames_ >= TAP_SILENCE_WARN_S * this->format_.sample_rate) { + this->silence_warned_ = true; + cli_log(LogLevel::WARN, + "coreaudio: input '%s' has captured only silence for %llu s. If something is " + "playing, macOS is withholding system audio: %s", + this->name().c_str(), static_cast(TAP_SILENCE_WARN_S), + SYSTEM_AUDIO_HOW_TO_GRANT); + } } void CoreAudioSource::list_devices(std::FILE* out) { @@ -119,12 +171,26 @@ void CoreAudioSource::list_devices(std::FILE* out) { } bool CoreAudioSource::negotiate(StreamFormat& format, std::string& error) { + if (this->loopback_) { + cli_log(LogLevel::INFO, + "coreaudio: --input loopback needs System Audio Recording permission, which macOS " + "asks for once; if it is refused the capture is silent -- %s", + SYSTEM_AUDIO_HOW_TO_GRANT); + const bool settled = this->negotiate_(format, error); + this->tap_.destroy(); + return settled; + } + return this->negotiate_(format, error); +} + +bool CoreAudioSource::negotiate_(StreamFormat& format, std::string& error) { AudioDeviceID device = kAudioObjectUnknown; - if (!resolve_ca_device(this->device_, CaDirection::Input, device, error)) { + if (!this->resolve_device_(device, error)) { return false; } - if (microphone_prompt_pending()) { + // A tap needs System Audio Recording, not the microphone. + if (!this->loopback_ && microphone_prompt_pending()) { // macOS aborts the process, uncatchably, when the app hosting the shell may not prompt. cli_log(LogLevel::INFO, "coreaudio: asking macOS for microphone access -- answer its prompt. If " @@ -132,10 +198,12 @@ bool CoreAudioSource::negotiate(StreamFormat& format, std::string& error) { "microphone: start it from Terminal.app"); } // Without it the unit opens, starts and delivers silence. - if (!request_microphone_access(error)) { + if (!this->loopback_ && !request_microphone_access(error)) { return false; } - const std::string label = "CoreAudio input device '" + ca_device_name(device) + "'"; + const std::string label = this->loopback_ + ? "the process tap for --input " + this->name() + : "CoreAudio input device '" + ca_device_name(device) + "'"; const uint32_t max_channels = ca_channels(device, CaDirection::Input); // The device's own rate: AUHAL converts depth and channels on capture, but does not resample. @@ -182,7 +250,7 @@ bool CoreAudioSource::open(const StreamFormat& format) { AudioDeviceID device = kAudioObjectUnknown; std::string error; - if (!resolve_ca_device(this->device_, CaDirection::Input, device, error)) { + if (!this->resolve_device_(device, error)) { cli_log(LogLevel::ERROR, "coreaudio: %s", error.c_str()); return false; } @@ -248,6 +316,8 @@ bool CoreAudioSource::open(const StreamFormat& format) { this->frames_read_ = 0; this->epoch_us_.store(0); this->frames_dropped_.store(0); + this->silent_frames_ = 0; + this->silence_warned_ = false; const uint32_t latency_frames = ca_u32_property(device, kAudioDevicePropertyLatency, kAudioDevicePropertyScopeInput) + @@ -334,6 +404,9 @@ int CoreAudioSource::read(uint8_t* data, size_t length, uint32_t timeout_ms, // Loaded before the read: the callback publishes it before the frames it covers. const int64_t epoch_us = this->epoch_us_.load(); this->ring_.read(data, bytes); + if (this->loopback_) { + this->check_tap_silence_(data, bytes); + } capture_time_us = epoch_us + static_cast(this->frames_read_) * US_PER_S / this->format_.sample_rate; this->frames_read_ += bytes / this->bytes_per_frame_; @@ -342,17 +415,18 @@ int CoreAudioSource::read(uint8_t* data, size_t length, uint32_t timeout_ms, void CoreAudioSource::close() { this->remove_listeners_(); - if (this->unit_ == nullptr) { - return; - } - AudioOutputUnitStop(this->unit_); - // Uninitialize waits the input callback out, so nothing below races it. - AudioUnitUninitialize(this->unit_); - AudioComponentInstanceDispose(this->unit_); - this->unit_ = nullptr; - this->device_id_ = kAudioObjectUnknown; - this->bytes_per_frame_ = 0; - this->ring_.reset(0); + if (this->unit_ != nullptr) { + AudioOutputUnitStop(this->unit_); + // Uninitialize waits the input callback out, so nothing below races it. + AudioUnitUninitialize(this->unit_); + AudioComponentInstanceDispose(this->unit_); + this->unit_ = nullptr; + this->device_id_ = kAudioObjectUnknown; + this->bytes_per_frame_ = 0; + this->ring_.reset(0); + } + // After the unit, which reads from it; an open that failed early may hold one too. + this->tap_.destroy(); } void CoreAudioSource::add_listeners_(AudioDeviceID device) { @@ -375,7 +449,14 @@ void CoreAudioSource::add_listeners_(AudioDeviceID device) { } this->listening_device_ = device; - if (this->device_.empty()) { + // The aggregate outlives a tapped device that goes away, so that death is watched directly. + if (this->tapped_output_ != kAudioObjectUnknown && + AudioObjectAddPropertyListener(this->tapped_output_, &alive, + &CoreAudioSource::property_listener, this) == noErr) { + this->listening_output_ = true; + } + + if (this->device_.empty() && !this->loopback_) { const AudioObjectPropertyAddress fallback = ca_address_of(kAudioHardwarePropertyDefaultInputDevice); if (AudioObjectAddPropertyListener(kAudioObjectSystemObject, &fallback, @@ -400,6 +481,12 @@ void CoreAudioSource::remove_listeners_() { this->listening_rate_ = false; } this->listening_device_ = kAudioObjectUnknown; + if (this->listening_output_) { + const AudioObjectPropertyAddress alive = ca_address_of(kAudioDevicePropertyDeviceIsAlive); + AudioObjectRemovePropertyListener(this->tapped_output_, &alive, + &CoreAudioSource::property_listener, this); + this->listening_output_ = false; + } if (this->listening_default_) { const AudioObjectPropertyAddress fallback = ca_address_of(kAudioHardwarePropertyDefaultInputDevice); diff --git a/src/coreaudio_source.h b/src/coreaudio_source.h index 30f266f..d76122b 100644 --- a/src/coreaudio_source.h +++ b/src/coreaudio_source.h @@ -17,6 +17,7 @@ #pragma once #include "audio_source.h" +#include "coreaudio_tap.h" #include "pcm_ring.h" #include @@ -34,11 +35,12 @@ namespace sendspin_cli { /// An AudioSource that captures through CoreAudio: `--input coreaudio:` an index, a device name, -/// or empty for the system default input. +/// or empty for the system default input. In loopback mode it captures a process tap instead: +/// `--input loopback:` names an output device the way -o does, or empty for every output. /// The input callback reads the format fields unlocked: change them only while no unit runs. class CoreAudioSource final : public AudioSource { public: - explicit CoreAudioSource(std::string device); + explicit CoreAudioSource(std::string device, bool loopback = false); ~CoreAudioSource() override; std::string name() const override; @@ -50,6 +52,9 @@ class CoreAudioSource final : public AudioSource { /// Prints this host's CoreAudio input devices. static void list_devices(std::FILE* out); + /// Prints the output devices `--input loopback:` can tap, numbered as -o counts them. + static void list_loopback_devices(std::FILE* out); + private: static OSStatus input_callback(void* user_data, AudioUnitRenderActionFlags* flags, const AudioTimeStamp* timestamp, UInt32 bus, UInt32 frames, @@ -59,12 +64,28 @@ class CoreAudioSource final : public AudioSource { static OSStatus property_listener(AudioObjectID object, UInt32 count, const AudioObjectPropertyAddress* addresses, void* user_data); + bool negotiate_(StreamFormat& format, std::string& error); + + /// Resolves the device to capture from; in loopback mode, creates the tap that is that device. + bool resolve_device_(AudioDeviceID& out, std::string& error); + + /// Warns once per open when a tap has delivered nothing but digital silence for a while. + void check_tap_silence_(const uint8_t* data, size_t bytes); + /// Removal does not wait an in-flight notification out; close() runs long before destruction. void add_listeners_(AudioDeviceID device); void remove_listeners_(); /// The device as --input spelled it, resolved per open. std::string device_; + bool loopback_; + + /// Loopback mode only: the tap captured from, and the output device it taps, if just one. + CoreAudioTap tap_; + AudioDeviceID tapped_output_{kAudioObjectUnknown}; + /// Consecutive all-zero frames read from the tap. Capture thread only. + uint64_t silent_frames_{0}; + bool silence_warned_{false}; AudioUnit unit_{nullptr}; AudioDeviceID device_id_{kAudioObjectUnknown}; @@ -100,6 +121,7 @@ class CoreAudioSource final : public AudioSource { bool listening_alive_{false}; bool listening_rate_{false}; bool listening_default_{false}; + bool listening_output_{false}; AudioDeviceID listening_device_{kAudioObjectUnknown}; }; diff --git a/src/coreaudio_tap.h b/src/coreaudio_tap.h new file mode 100644 index 0000000..0095a02 --- /dev/null +++ b/src/coreaudio_tap.h @@ -0,0 +1,58 @@ +// 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. + +/// A CoreAudio process tap on this host's output, read through a private aggregate device. + +#pragma once + +#include + +#include + +namespace sendspin_cli { + +/// How to grant System Audio Recording, for logs that need to say so. +extern const char* const SYSTEM_AUDIO_HOW_TO_GRANT; + +/// A tap on everything this host plays, or on one output device, minus this process's own +/// playback. The tap lives inside a private aggregate device that AUHAL can capture from. +class CoreAudioTap { +public: + CoreAudioTap() = default; + ~CoreAudioTap(); + + CoreAudioTap(const CoreAudioTap&) = delete; + CoreAudioTap& operator=(const CoreAudioTap&) = delete; + + /// True if this build and this macOS can tap, else false with `error` saying why. + static bool supported(std::string& error); + + /// Creates the tap and its aggregate device, replacing any it already holds. + /// @param output The output device to tap, or kAudioObjectUnknown for every output. + bool create(AudioDeviceID output, std::string& error); + + /// Destroys the aggregate device, then the tap. Idempotent. + void destroy(); + + /// The aggregate device to capture from, or kAudioObjectUnknown before create(). + AudioDeviceID device() const { + return this->aggregate_; + } + +private: + AudioObjectID tap_{kAudioObjectUnknown}; + AudioDeviceID aggregate_{kAudioObjectUnknown}; +}; + +} // namespace sendspin_cli diff --git a/src/coreaudio_tap.mm b/src/coreaudio_tap.mm new file mode 100644 index 0000000..07ee73a --- /dev/null +++ b/src/coreaudio_tap.mm @@ -0,0 +1,152 @@ +// 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_tap.h" + +#include "coreaudio_device.h" + +#import + +#include + +// Process taps arrived in the macOS 14.2 SDK; older SDKs build without them. +#if __MAC_OS_X_VERSION_MAX_ALLOWED >= 140200 +#define SENDSPIN_CLI_HAVE_PROCESS_TAP 1 +#import +#import +#endif + +namespace sendspin_cli { + +const char* const SYSTEM_AUDIO_HOW_TO_GRANT = + "allow it in System Settings > Privacy & Security > Screen & System Audio Recording, under " + "System Audio Recording Only, for the app sendspin-cli is started from (the terminal, or " + "sendspin-cli itself under launchd). After a refusal macOS never asks again by itself: " + "`tccutil reset AudioCapture` makes it ask on the next start"; + +CoreAudioTap::~CoreAudioTap() { + this->destroy(); +} + +bool CoreAudioTap::supported(std::string& error) { +#ifdef SENDSPIN_CLI_HAVE_PROCESS_TAP + if (@available(macOS 14.2, *)) { + return true; + } + error = "--input loopback needs macOS 14.2 or later, which added the process taps it " + "captures through"; +#else + error = "--input loopback needs process taps, and this build of sendspin-cli was made with a " + "macOS SDK older than 14.2, which has none"; +#endif + return false; +} + +bool CoreAudioTap::create(AudioDeviceID output, std::string& error) { + this->destroy(); + if (!supported(error)) { + return false; + } +#ifdef SENDSPIN_CLI_HAVE_PROCESS_TAP + if (@available(macOS 14.2, *)) { + @autoreleasepool { + NSString* output_uid = nil; + if (output != kAudioObjectUnknown) { + CFStringRef uid = nullptr; + UInt32 size = sizeof(uid); + const AudioObjectPropertyAddress address = ca_address_of(kAudioDevicePropertyDeviceUID); + if (AudioObjectGetPropertyData(output, &address, 0, nullptr, &size, &uid) != noErr || + uid == nullptr) { + error = "output device '" + ca_device_name(output) + "' has no UID to tap by"; + return false; + } + output_uid = (__bridge_transfer NSString*)uid; + } + + // Our own playback is left out, so a player on this host is never looped back. + NSMutableArray* excluded = [NSMutableArray array]; + pid_t pid = getpid(); + AudioObjectID self_object = kAudioObjectUnknown; + UInt32 size = sizeof(self_object); + const AudioObjectPropertyAddress translate = + ca_address_of(kAudioHardwarePropertyTranslatePIDToProcessObject); + if (AudioObjectGetPropertyData(kAudioObjectSystemObject, &translate, sizeof(pid), &pid, + &size, &self_object) == noErr && + self_object != kAudioObjectUnknown) { + [excluded addObject:@(self_object)]; + } + + CATapDescription* description = + (output_uid == nil) + ? [[CATapDescription alloc] initStereoGlobalTapButExcludeProcesses:excluded] + : [[CATapDescription alloc] initExcludingProcesses:excluded + andDeviceUID:output_uid + withStream:0]; + description.name = @"sendspin-cli loopback"; + description.privateTap = YES; + description.muteBehavior = CATapUnmuted; + + OSStatus err = AudioHardwareCreateProcessTap(description, &this->tap_); + if (err != noErr) { + this->tap_ = kAudioObjectUnknown; + error = "macOS would not create a process tap: " + ca_status_text(err); + return false; + } + + NSDictionary* aggregate = @{ + @kAudioAggregateDeviceNameKey : @"sendspin-cli loopback", + @kAudioAggregateDeviceUIDKey : [[NSUUID UUID] UUIDString], + @kAudioAggregateDeviceIsPrivateKey : @YES, + @kAudioAggregateDeviceTapAutoStartKey : @YES, + @kAudioAggregateDeviceTapListKey : @[ @{ + @kAudioSubTapUIDKey : description.UUID.UUIDString, + @kAudioSubTapDriftCompensationKey : @YES, + } ], + }; + err = AudioHardwareCreateAggregateDevice((__bridge CFDictionaryRef)aggregate, + &this->aggregate_); + if (err != noErr) { + this->aggregate_ = kAudioObjectUnknown; + error = "macOS would not create the aggregate device a process tap is read " + "through: " + + ca_status_text(err); + this->destroy(); + return false; + } + } + return true; + } +#else + (void)output; +#endif + return false; +} + +void CoreAudioTap::destroy() { +#ifdef SENDSPIN_CLI_HAVE_PROCESS_TAP + if (@available(macOS 14.2, *)) { + // The aggregate first: it holds the tap. + if (this->aggregate_ != kAudioObjectUnknown) { + AudioHardwareDestroyAggregateDevice(this->aggregate_); + } + if (this->tap_ != kAudioObjectUnknown) { + AudioHardwareDestroyProcessTap(this->tap_); + } + } +#endif + this->aggregate_ = kAudioObjectUnknown; + this->tap_ = kAudioObjectUnknown; +} + +} // namespace sendspin_cli diff --git a/tests/audio_source_test.cpp b/tests/audio_source_test.cpp index 83da877..80960e5 100644 --- a/tests/audio_source_test.cpp +++ b/tests/audio_source_test.cpp @@ -240,7 +240,7 @@ TEST(ResolveInputSpec, WithoutPipeWireItsNameIsNotInThisBuild) { } #endif -#if defined(SENDSPIN_CLI_HAVE_PULSE) || defined(SENDSPIN_CLI_HAVE_PIPEWIRE) +#ifdef SENDSPIN_CLI_HAVE_LOOPBACK TEST(ResolveInputSpec, LoopbackTakesASinkOrTheDefault) { for (const char* spec : {"loopback", "loopback:default"}) { EXPECT_EQ(resolved(spec).backend, SourceBackend::Loopback) << spec; @@ -268,6 +268,7 @@ TEST(ResolveInputSpec, TheBackendFormsAreUnchangedByLoopback) { #endif } +#ifndef SENDSPIN_CLI_HAVE_COREAUDIO TEST(MakeAudioSource, LoopbackWithNoSoundServerNamesEveryOneTried) { const ScopedEnv remote("PIPEWIRE_REMOTE", "/nonexistent/sendspin-cli-test"); const ScopedEnv server("PULSE_SERVER", "unix:/nonexistent/sendspin-cli-test"); @@ -281,11 +282,12 @@ TEST(MakeAudioSource, LoopbackWithNoSoundServerNamesEveryOneTried) { EXPECT_NE(error.find("PulseAudio"), std::string::npos) << error; #endif } +#endif #else TEST(ResolveInputSpec, LoopbackNeedsASoundServerBackend) { for (const char* spec : {"loopback", "loopback:sink"}) { const std::string error = rejected(spec); - EXPECT_NE(error.find("PipeWire or PulseAudio"), std::string::npos) << spec; + EXPECT_NE(error.find("PipeWire, PulseAudio or CoreAudio"), std::string::npos) << spec; EXPECT_NE(error.find(input_backend_list()), std::string::npos) << spec; } EXPECT_EQ(input_backend_list().find("loopback"), std::string::npos); From bd97cecb49a4ed015ea4c5018f409b2db2780699 Mon Sep 17 00:00:00 2001 From: Chris Uthe Date: Thu, 15 Oct 2026 13:41:49 -0500 Subject: [PATCH 2/5] Watch the tap's format and tidy loopback errors on macOS A failed open now releases its tap at once, the tap's format is watched for a rate change on the tapped output, and errors and logs name the loopback as typed rather than its private aggregate device. --- src/coreaudio_source.cpp | 43 ++++++++++++++++++++++++++++++++++++---- src/coreaudio_source.h | 1 + src/coreaudio_tap.h | 8 ++++++++ src/coreaudio_tap.mm | 10 ++++++++++ 4 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/coreaudio_source.cpp b/src/coreaudio_source.cpp index 876d030..7e3aed1 100644 --- a/src/coreaudio_source.cpp +++ b/src/coreaudio_source.cpp @@ -41,10 +41,10 @@ constexpr uint32_t MIN_RENDER_FRAMES = 4096; constexpr int64_t US_PER_S = 1000000; -/// AUHAL's elements: 1 faces the device's input side, 0 its output side. /// How long a tap may deliver only digital silence before the permission hint is logged. constexpr uint64_t TAP_SILENCE_WARN_S = 10; +/// AUHAL's elements: 1 faces the device's input side, 0 its output side. constexpr UInt32 INPUT_BUS = 1; constexpr UInt32 OUTPUT_BUS = 0; @@ -209,8 +209,10 @@ bool CoreAudioSource::negotiate_(StreamFormat& format, std::string& error) { // The device's own rate: AUHAL converts depth and channels on capture, but does not resample. const uint32_t rate = device_rate(device); if (max_channels == 0 || rate == 0) { - error = label + " went away before it could be asked what it captures -- run with -l to " - "list this host's input devices"; + error = label + + " went away before it could be asked what it captures -- run with -l to " + "list this host's " + + (this->loopback_ ? "output" : "input") + " devices"; return false; } const uint8_t wanted = (format.channels != 0) ? format.channels : uint8_t{2}; @@ -254,12 +256,14 @@ bool CoreAudioSource::open(const StreamFormat& format) { cli_log(LogLevel::ERROR, "coreaudio: %s", error.c_str()); return false; } - const std::string label = ca_device_name(device); + // The aggregate a tap is read through is private, so a loopback is named as typed. + const std::string label = this->loopback_ ? this->name() : ca_device_name(device); AudioStreamBasicDescription asbd = {}; if (!ca_asbd_for(format.sample_rate, format.channels, format.bit_depth, asbd)) { cli_log(LogLevel::ERROR, "coreaudio: refusing capture at %u Hz / %u ch / %u-bit", format.sample_rate, format.channels, format.bit_depth); + this->close(); return false; } // Exact: the role announced this rate, and nothing here resamples. @@ -269,11 +273,13 @@ bool CoreAudioSource::open(const StreamFormat& format) { "coreaudio: input '%s' runs at %u Hz, not the %u Hz this run started with -- set " "it back in Audio MIDI Setup, or restart sendspin-cli", label.c_str(), rate, format.sample_rate); + this->close(); return false; } if (format.channels > ca_channels(device, CaDirection::Input)) { cli_log(LogLevel::ERROR, "coreaudio: input '%s' has %u channels, so it cannot capture %u", label.c_str(), ca_channels(device, CaDirection::Input), format.channels); + this->close(); return false; } @@ -282,6 +288,7 @@ bool CoreAudioSource::open(const StreamFormat& format) { if (unit == nullptr) { cli_log(LogLevel::ERROR, "coreaudio: cannot open input '%s': %s", label.c_str(), ca_status_text(err).c_str()); + this->close(); return false; } // Held from here on so every failure below goes out through close(). @@ -354,6 +361,18 @@ bool CoreAudioSource::open(const StreamFormat& format) { return false; } + if (this->loopback_) { + const std::string tapped = + (this->tapped_output_ == kAudioObjectUnknown) + ? std::string("everything this Mac plays") + : "output device '" + ca_device_name(this->tapped_output_) + "'"; + cli_log(LogLevel::INFO, + "coreaudio: input '%s' open at %u Hz, %u ch, %u-bit -- tapping %s, without " + "sendspin-cli's own playback", + label.c_str(), format.sample_rate, format.channels, format.bit_depth, + tapped.c_str()); + return true; + } cli_log(LogLevel::INFO, "coreaudio: input '%s' (%s) open at %u Hz, %u ch, %u-bit", label.c_str(), this->name().c_str(), format.sample_rate, format.channels, format.bit_depth); @@ -449,6 +468,15 @@ void CoreAudioSource::add_listeners_(AudioDeviceID device) { } this->listening_device_ = device; + // A tapped output's rate change reaches the tap's format, not the aggregate's own rate. + if (this->tap_.tap() != kAudioObjectUnknown) { + const AudioObjectPropertyAddress format = ca_address_of(TAP_FORMAT_SELECTOR); + if (AudioObjectAddPropertyListener(this->tap_.tap(), &format, + &CoreAudioSource::property_listener, this) == noErr) { + this->listening_tap_ = this->tap_.tap(); + } + } + // The aggregate outlives a tapped device that goes away, so that death is watched directly. if (this->tapped_output_ != kAudioObjectUnknown && AudioObjectAddPropertyListener(this->tapped_output_, &alive, @@ -481,6 +509,12 @@ void CoreAudioSource::remove_listeners_() { this->listening_rate_ = false; } this->listening_device_ = kAudioObjectUnknown; + if (this->listening_tap_ != kAudioObjectUnknown) { + const AudioObjectPropertyAddress format = ca_address_of(TAP_FORMAT_SELECTOR); + AudioObjectRemovePropertyListener(this->listening_tap_, &format, + &CoreAudioSource::property_listener, this); + this->listening_tap_ = kAudioObjectUnknown; + } if (this->listening_output_) { const AudioObjectPropertyAddress alive = ca_address_of(kAudioDevicePropertyDeviceIsAlive); AudioObjectRemovePropertyListener(this->tapped_output_, &alive, @@ -504,6 +538,7 @@ OSStatus CoreAudioSource::property_listener(AudioObjectID /*object*/, UInt32 cou switch (addresses[i].mSelector) { case kAudioDevicePropertyDeviceIsAlive: case kAudioDevicePropertyNominalSampleRate: + case TAP_FORMAT_SELECTOR: self->device_lost_.store(true); self->data_ready_.notify_all(); break; diff --git a/src/coreaudio_source.h b/src/coreaudio_source.h index d76122b..e607e83 100644 --- a/src/coreaudio_source.h +++ b/src/coreaudio_source.h @@ -122,6 +122,7 @@ class CoreAudioSource final : public AudioSource { bool listening_rate_{false}; bool listening_default_{false}; bool listening_output_{false}; + AudioObjectID listening_tap_{kAudioObjectUnknown}; AudioDeviceID listening_device_{kAudioObjectUnknown}; }; diff --git a/src/coreaudio_tap.h b/src/coreaudio_tap.h index 0095a02..5b9a83b 100644 --- a/src/coreaudio_tap.h +++ b/src/coreaudio_tap.h @@ -22,6 +22,9 @@ namespace sendspin_cli { +/// kAudioTapPropertyFormat, which SDKs before 14.2 lack; it changes with the tapped output's rate. +inline constexpr AudioObjectPropertySelector TAP_FORMAT_SELECTOR = 0x74666d74; // 'tfmt' + /// How to grant System Audio Recording, for logs that need to say so. extern const char* const SYSTEM_AUDIO_HOW_TO_GRANT; @@ -50,6 +53,11 @@ class CoreAudioTap { return this->aggregate_; } + /// The tap itself, whose TAP_FORMAT_SELECTOR can be listened to; kAudioObjectUnknown if none. + AudioObjectID tap() const { + return this->tap_; + } + private: AudioObjectID tap_{kAudioObjectUnknown}; AudioDeviceID aggregate_{kAudioObjectUnknown}; diff --git a/src/coreaudio_tap.mm b/src/coreaudio_tap.mm index 07ee73a..2fc0465 100644 --- a/src/coreaudio_tap.mm +++ b/src/coreaudio_tap.mm @@ -15,6 +15,7 @@ #include "coreaudio_tap.h" #include "coreaudio_device.h" +#include "log.h" #import @@ -25,10 +26,15 @@ #define SENDSPIN_CLI_HAVE_PROCESS_TAP 1 #import #import +static_assert(sendspin_cli::TAP_FORMAT_SELECTOR == kAudioTapPropertyFormat); #endif namespace sendspin_cli { +#ifdef SENDSPIN_CLI_HAVE_PROCESS_TAP +static constexpr const char* LOG_TAG = LOG_TAG_AUDIO; +#endif + const char* const SYSTEM_AUDIO_HOW_TO_GRANT = "allow it in System Settings > Privacy & Security > Screen & System Audio Recording, under " "System Audio Recording Only, for the app sendspin-cli is started from (the terminal, or " @@ -85,6 +91,10 @@ &size, &self_object) == noErr && self_object != kAudioObjectUnknown) { [excluded addObject:@(self_object)]; + } else { + cli_log(sendspin::LogLevel::WARN, + "coreaudio: macOS cannot name sendspin-cli's own audio process, so the " + "loopback tap includes this player's own playback"); } CATapDescription* description = From 5905c47996377ce371b4de0a286c19cc7d06a3fb Mon Sep 17 00:00:00 2001 From: Chris Uthe Date: Thu, 15 Oct 2026 13:47:15 -0500 Subject: [PATCH 3/5] Warn when a CoreAudio input is the virtual device -o plays to Capturing a loopback driver that the player also plays into sends the server its own audio back, so the player logs one warning at startup. --- docs/wiki/Advanced-Usage.md | 4 ++++ src/audio_source.cpp | 25 +++++++++++++++++++++++++ src/audio_source.h | 6 ++++++ src/main.cpp | 8 ++++++++ tests/audio_source_test.cpp | 8 ++++++++ 5 files changed, 51 insertions(+) diff --git a/docs/wiki/Advanced-Usage.md b/docs/wiki/Advanced-Usage.md index a0c874e..87498a8 100644 --- a/docs/wiki/Advanced-Usage.md +++ b/docs/wiki/Advanced-Usage.md @@ -147,6 +147,10 @@ sendspin-cli -n den --input loopback sendspin-cli -n den --input "loopback:MacBook Pro Speakers" ``` +A virtual loopback device installed by another app is an ordinary input: +`--input "coreaudio:"`. If `-o` plays into that same device, the player logs a +warning at startup, since the server would get its own audio back. + macOS asks once for **System Audio Recording**. It names the app sendspin-cli is started from: the terminal, or `sendspin-cli` itself under `launchd`. If the answer is no, macOS gives the tap silence rather than an error, so the player keeps running, and logs a diff --git a/src/audio_source.cpp b/src/audio_source.cpp index c0ca7f5..d68cbb5 100644 --- a/src/audio_source.cpp +++ b/src/audio_source.cpp @@ -28,6 +28,7 @@ #include "pulse_source.h" #endif #ifdef SENDSPIN_CLI_HAVE_COREAUDIO +#include "coreaudio_device.h" #include "coreaudio_source.h" #endif @@ -376,6 +377,30 @@ bool may_capture_own_playback(const DeviceSpec& output, const std::string& loope return true; } +#ifdef SENDSPIN_CLI_HAVE_COREAUDIO +bool coreaudio_input_hears_output(const std::string& input, const std::string& output) { + InputSpec captured_spec; + DeviceSpec played_spec; + std::string error; + if (!resolve_input_spec(input, captured_spec, error) || + captured_spec.backend != SourceBackend::CoreAudio || + !resolve_device_spec(output, played_spec, error) || + played_spec.backend != SinkBackend::CoreAudio) { + return false; + } + AudioDeviceID captured = kAudioObjectUnknown; + AudioDeviceID played = kAudioObjectUnknown; + if (!resolve_ca_device(captured_spec.device, CaDirection::Input, captured, error) || + !resolve_ca_device(played_spec.device, CaDirection::Output, played, error)) { + return false; + } + // A hardware device's input is a microphone or line-in, not what the device plays. + return captured == played && + ca_u32_property(captured, kAudioDevicePropertyTransportType, + kAudioObjectPropertyScopeGlobal) == kAudioDeviceTransportTypeVirtual; +} +#endif + std::unique_ptr make_audio_source(const std::string& spec, StreamFormat& format, std::string& error) { InputSpec resolved; diff --git a/src/audio_source.h b/src/audio_source.h index cb939bc..9799b3e 100644 --- a/src/audio_source.h +++ b/src/audio_source.h @@ -155,6 +155,12 @@ bool input_pcm_is_reachable(const std::string& pcm); /// True unless `output` provably plays somewhere other than `looped_sink` (empty: the default). bool may_capture_own_playback(const DeviceSpec& output, const std::string& looped_sink); +#ifdef SENDSPIN_CLI_HAVE_COREAUDIO +/// True if --input `input` captures a virtual CoreAudio device, such as a loopback driver, that +/// -o `output` also plays to. +bool coreaudio_input_hears_output(const std::string& input, const std::string& output); +#endif + /// Settles `format` for a sound server, which converts: only a format no stream can carry changes. void settle_server_capture_format(StreamFormat& format); diff --git a/src/main.cpp b/src/main.cpp index 660a136..6cd2bf5 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -736,6 +736,14 @@ int main(int argc, char* argv[]) { "server its own audio back -- loop back a sink that -o does not play to", opts.input.c_str(), opts.device.c_str()); } +#ifdef SENDSPIN_CLI_HAVE_COREAUDIO + if (coreaudio_input_hears_output(opts.input, opts.device)) { + log_line(LogLevel::WARN, LOG_TAG_AUDIO, + "--input %s captures the virtual device -o %s plays to, sending the server " + "its own audio back -- play to real speakers with -o coreaudio:", + opts.input.c_str(), opts.device.c_str()); + } +#endif } // Loaded before the client, since its values must be in place before anything is published. diff --git a/tests/audio_source_test.cpp b/tests/audio_source_test.cpp index 80960e5..fbb21bf 100644 --- a/tests/audio_source_test.cpp +++ b/tests/audio_source_test.cpp @@ -296,6 +296,14 @@ TEST(ResolveInputSpec, LoopbackNeedsASoundServerBackend) { // When a loopback might hear this player's own output +#ifdef SENDSPIN_CLI_HAVE_COREAUDIO +TEST(CoreaudioInputHearsOutput, OnlyCoreaudioOnBothSidesIsChecked) { + EXPECT_FALSE(coreaudio_input_hears_output("tone", "coreaudio")); + EXPECT_FALSE(coreaudio_input_hears_output("coreaudio", "null")); + EXPECT_FALSE(coreaudio_input_hears_output("loopback", "coreaudio")); +} +#endif + TEST(MayCaptureOwnPlayback, NotWhenNothingIsPlayedToASink) { EXPECT_FALSE(may_capture_own_playback({SinkBackend::Null, ""}, "")); EXPECT_FALSE(may_capture_own_playback({SinkBackend::Stdout, ""}, "speakers")); From c62f19403f154ab260a882127509729b27fff8cc Mon Sep 17 00:00:00 2001 From: Chris Uthe Date: Thu, 15 Oct 2026 13:50:23 -0500 Subject: [PATCH 4/5] Warn too when -o is a Multi-Output Device holding the captured device --- docs/wiki/Advanced-Usage.md | 4 ++-- src/audio_source.cpp | 24 +++++++++++++++++++++--- src/audio_source.h | 2 +- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/docs/wiki/Advanced-Usage.md b/docs/wiki/Advanced-Usage.md index bfbe5e8..5769c01 100644 --- a/docs/wiki/Advanced-Usage.md +++ b/docs/wiki/Advanced-Usage.md @@ -151,8 +151,8 @@ sendspin-cli -n den --input "loopback:MacBook Pro Speakers" ``` A virtual loopback device installed by another app is an ordinary input: -`--input "coreaudio:"`. If `-o` plays into that same device, the player logs a -warning at startup, since the server would get its own audio back. +`--input "coreaudio:"`. If `-o` plays into that same device, or into a Multi-Output +Device that includes it, the player logs a warning at startup, since the server would get its own audio back. macOS asks once for **System Audio Recording**. It names the app sendspin-cli is started from: the terminal, or `sendspin-cli` itself under `launchd`. If the answer is no, macOS diff --git a/src/audio_source.cpp b/src/audio_source.cpp index 264eeb4..cfbda6c 100644 --- a/src/audio_source.cpp +++ b/src/audio_source.cpp @@ -407,9 +407,27 @@ bool coreaudio_input_hears_output(const std::string& input, const std::string& o return false; } // A hardware device's input is a microphone or line-in, not what the device plays. - return captured == played && - ca_u32_property(captured, kAudioDevicePropertyTransportType, - kAudioObjectPropertyScopeGlobal) == kAudioDeviceTransportTypeVirtual; + if (ca_u32_property(captured, kAudioDevicePropertyTransportType, + kAudioObjectPropertyScopeGlobal) != kAudioDeviceTransportTypeVirtual) { + return false; + } + if (captured == played) { + return true; + } + // A Multi-Output Device plays into each of its sub-devices. + const AudioObjectPropertyAddress address = + ca_address_of(kAudioAggregateDevicePropertyActiveSubDeviceList); + UInt32 size = 0; + if (AudioObjectGetPropertyDataSize(played, &address, 0, nullptr, &size) != noErr) { + return false; + } + std::vector subdevices(size / sizeof(AudioObjectID)); + if (subdevices.empty() || AudioObjectGetPropertyData(played, &address, 0, nullptr, &size, + subdevices.data()) != noErr) { + return false; + } + subdevices.resize(size / sizeof(AudioObjectID)); + return std::find(subdevices.begin(), subdevices.end(), captured) != subdevices.end(); } #endif diff --git a/src/audio_source.h b/src/audio_source.h index ac10c36..d781609 100644 --- a/src/audio_source.h +++ b/src/audio_source.h @@ -158,7 +158,7 @@ bool may_capture_own_playback(const DeviceSpec& output, const std::string& loope #ifdef SENDSPIN_CLI_HAVE_COREAUDIO /// True if --input `input` captures a virtual CoreAudio device, such as a loopback driver, that -/// -o `output` also plays to. +/// -o `output` also plays to, directly or as part of a Multi-Output Device. bool coreaudio_input_hears_output(const std::string& input, const std::string& output); #endif From 748fd8a2957f2e384ac3f13bd8b5d9623e1ea6e6 Mon Sep 17 00:00:00 2001 From: Chris Uthe Date: Thu, 15 Oct 2026 14:18:04 -0500 Subject: [PATCH 5/5] Refuse a tap that cannot leave out our own playback, and drop the tap-format listener The aggregate's own rate listener already catches a tapped output's rate change, so watching the tap's format only added a second path to the same reopen. --- src/coreaudio_source.cpp | 16 ---------------- src/coreaudio_source.h | 1 - src/coreaudio_tap.h | 8 -------- src/coreaudio_tap.mm | 20 ++++++-------------- 4 files changed, 6 insertions(+), 39 deletions(-) diff --git a/src/coreaudio_source.cpp b/src/coreaudio_source.cpp index 7e3aed1..9a90e55 100644 --- a/src/coreaudio_source.cpp +++ b/src/coreaudio_source.cpp @@ -468,15 +468,6 @@ void CoreAudioSource::add_listeners_(AudioDeviceID device) { } this->listening_device_ = device; - // A tapped output's rate change reaches the tap's format, not the aggregate's own rate. - if (this->tap_.tap() != kAudioObjectUnknown) { - const AudioObjectPropertyAddress format = ca_address_of(TAP_FORMAT_SELECTOR); - if (AudioObjectAddPropertyListener(this->tap_.tap(), &format, - &CoreAudioSource::property_listener, this) == noErr) { - this->listening_tap_ = this->tap_.tap(); - } - } - // The aggregate outlives a tapped device that goes away, so that death is watched directly. if (this->tapped_output_ != kAudioObjectUnknown && AudioObjectAddPropertyListener(this->tapped_output_, &alive, @@ -509,12 +500,6 @@ void CoreAudioSource::remove_listeners_() { this->listening_rate_ = false; } this->listening_device_ = kAudioObjectUnknown; - if (this->listening_tap_ != kAudioObjectUnknown) { - const AudioObjectPropertyAddress format = ca_address_of(TAP_FORMAT_SELECTOR); - AudioObjectRemovePropertyListener(this->listening_tap_, &format, - &CoreAudioSource::property_listener, this); - this->listening_tap_ = kAudioObjectUnknown; - } if (this->listening_output_) { const AudioObjectPropertyAddress alive = ca_address_of(kAudioDevicePropertyDeviceIsAlive); AudioObjectRemovePropertyListener(this->tapped_output_, &alive, @@ -538,7 +523,6 @@ OSStatus CoreAudioSource::property_listener(AudioObjectID /*object*/, UInt32 cou switch (addresses[i].mSelector) { case kAudioDevicePropertyDeviceIsAlive: case kAudioDevicePropertyNominalSampleRate: - case TAP_FORMAT_SELECTOR: self->device_lost_.store(true); self->data_ready_.notify_all(); break; diff --git a/src/coreaudio_source.h b/src/coreaudio_source.h index e607e83..d76122b 100644 --- a/src/coreaudio_source.h +++ b/src/coreaudio_source.h @@ -122,7 +122,6 @@ class CoreAudioSource final : public AudioSource { bool listening_rate_{false}; bool listening_default_{false}; bool listening_output_{false}; - AudioObjectID listening_tap_{kAudioObjectUnknown}; AudioDeviceID listening_device_{kAudioObjectUnknown}; }; diff --git a/src/coreaudio_tap.h b/src/coreaudio_tap.h index 5b9a83b..0095a02 100644 --- a/src/coreaudio_tap.h +++ b/src/coreaudio_tap.h @@ -22,9 +22,6 @@ namespace sendspin_cli { -/// kAudioTapPropertyFormat, which SDKs before 14.2 lack; it changes with the tapped output's rate. -inline constexpr AudioObjectPropertySelector TAP_FORMAT_SELECTOR = 0x74666d74; // 'tfmt' - /// How to grant System Audio Recording, for logs that need to say so. extern const char* const SYSTEM_AUDIO_HOW_TO_GRANT; @@ -53,11 +50,6 @@ class CoreAudioTap { return this->aggregate_; } - /// The tap itself, whose TAP_FORMAT_SELECTOR can be listened to; kAudioObjectUnknown if none. - AudioObjectID tap() const { - return this->tap_; - } - private: AudioObjectID tap_{kAudioObjectUnknown}; AudioDeviceID aggregate_{kAudioObjectUnknown}; diff --git a/src/coreaudio_tap.mm b/src/coreaudio_tap.mm index 2fc0465..952b6bc 100644 --- a/src/coreaudio_tap.mm +++ b/src/coreaudio_tap.mm @@ -15,7 +15,6 @@ #include "coreaudio_tap.h" #include "coreaudio_device.h" -#include "log.h" #import @@ -26,15 +25,10 @@ #define SENDSPIN_CLI_HAVE_PROCESS_TAP 1 #import #import -static_assert(sendspin_cli::TAP_FORMAT_SELECTOR == kAudioTapPropertyFormat); #endif namespace sendspin_cli { -#ifdef SENDSPIN_CLI_HAVE_PROCESS_TAP -static constexpr const char* LOG_TAG = LOG_TAG_AUDIO; -#endif - const char* const SYSTEM_AUDIO_HOW_TO_GRANT = "allow it in System Settings > Privacy & Security > Screen & System Audio Recording, under " "System Audio Recording Only, for the app sendspin-cli is started from (the terminal, or " @@ -81,21 +75,19 @@ } // Our own playback is left out, so a player on this host is never looped back. - NSMutableArray* excluded = [NSMutableArray array]; pid_t pid = getpid(); AudioObjectID self_object = kAudioObjectUnknown; UInt32 size = sizeof(self_object); const AudioObjectPropertyAddress translate = ca_address_of(kAudioHardwarePropertyTranslatePIDToProcessObject); if (AudioObjectGetPropertyData(kAudioObjectSystemObject, &translate, sizeof(pid), &pid, - &size, &self_object) == noErr && - self_object != kAudioObjectUnknown) { - [excluded addObject:@(self_object)]; - } else { - cli_log(sendspin::LogLevel::WARN, - "coreaudio: macOS cannot name sendspin-cli's own audio process, so the " - "loopback tap includes this player's own playback"); + &size, &self_object) != noErr || + self_object == kAudioObjectUnknown) { + error = "macOS cannot name sendspin-cli's own audio process, so a tap could not " + "leave out this player's own playback"; + return false; } + NSArray* excluded = @[ @(self_object) ]; CATapDescription* description = (output_uid == nil)