diff --git a/CMakeLists.txt b/CMakeLists.txt index 6a5d4a4..21581a6 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 8471158..e63e534 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`, `pipewire:` or `portaudio`, 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`, `pipewire:` or `portaudio`, 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 db592c2..5769c01 100644 --- a/docs/wiki/Advanced-Usage.md +++ b/docs/wiki/Advanced-Usage.md @@ -140,6 +140,28 @@ 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" +``` + +A virtual loopback device installed by another app is an ordinary input: +`--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 +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 or PortAudio diff --git a/docs/wiki/Configuration.md b/docs/wiki/Configuration.md index 653160d..e8bc494 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, `portaudio[:]`, `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, `portaudio[:]`, `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 9fc1df5..cfbda6c 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 #ifdef SENDSPIN_CLI_HAVE_PORTAUDIO @@ -66,10 +67,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 +91,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 @@ -308,8 +320,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; } @@ -377,6 +389,48 @@ 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. + 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 + std::unique_ptr make_audio_source(const std::string& spec, StreamFormat& format, std::string& error) { InputSpec resolved; @@ -413,7 +467,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; @@ -520,6 +574,16 @@ void print_capture_devices(std::FILE* out) { "until sendspin-cli restarts; while --input portaudio runs, that goes for\n" "-o portaudio's device too.\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) && \ !defined(SENDSPIN_CLI_HAVE_PORTAUDIO) diff --git a/src/audio_source.h b/src/audio_source.h index 24dd911..d781609 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. @@ -150,6 +156,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, directly or as part of a Multi-Output Device. +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/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..9a90e55 100644 --- a/src/coreaudio_source.cpp +++ b/src/coreaudio_source.cpp @@ -41,6 +41,9 @@ constexpr uint32_t MIN_RENDER_FRAMES = 4096; constexpr int64_t US_PER_S = 1000000; +/// 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; @@ -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,17 +198,21 @@ 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. 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}; @@ -182,16 +252,18 @@ 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; } - 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. @@ -201,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; } @@ -214,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(). @@ -248,6 +323,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) + @@ -284,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); @@ -334,6 +423,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 +434,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 +468,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 +500,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..952b6bc --- /dev/null +++ b/src/coreaudio_tap.mm @@ -0,0 +1,154 @@ +// 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. + 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) { + 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) + ? [[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/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 cd7e193..37ff5ca 100644 --- a/tests/audio_source_test.cpp +++ b/tests/audio_source_test.cpp @@ -273,7 +273,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; @@ -301,6 +301,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"); @@ -314,11 +315,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); @@ -327,6 +329,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"));