diff --git a/README.md b/README.md index 18e36a1..8471158 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 ` | A capture device to stream to the server: a microphone or line-in, e.g. `default`, `hw:1,0`, `pulse`, `pipewire:` or `portaudio`, or `coreaudio` on macOS. `-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). | +| `--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). | | `--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 833cadd..db592c2 100644 --- a/docs/wiki/Advanced-Usage.md +++ b/docs/wiki/Advanced-Usage.md @@ -89,6 +89,8 @@ argument the way `-o` does: `coreaudio:` for one device from `-l`'s list. - `portaudio` for PortAudio's default input as of startup, or `portaudio:` for one device from `-l`'s list, in a build with the PortAudio backend. +- `loopback` or `loopback:` for what this host plays; see + [Stream what this host plays](#stream-what-this-host-plays). - `tone` and `null`, which need no sound card: `tone` streams a 440 Hz test tone, which checks the path to the server without a microphone, and `null` streams silence. @@ -117,6 +119,27 @@ can still reach that server through ALSA's own PCMs: `--input alsa:pulse`, `--input alsa:pipewire`. A PortAudio device plugged in or made default after startup is not seen until `sendspin-cli` restarts. +### Stream what this host plays + +`--input loopback` streams whatever this host plays through its default sink, such as a +browser or a desktop music app, to the server. It follows the default sink if it +changes. `--input loopback:` captures one sink instead: + +```bash +sendspin-cli -n den --input loopback +sendspin-cli -n den --input loopback:alsa_output.pci-0000_00_1f.3.analog-stereo +``` + +`sendspin-cli -l` lists the sinks under **Loopback**. `loopback` uses PipeWire if it is +running and PulseAudio otherwise. The backend forms do the same: `pipewire:` and +`pulse:` capture a sink as well as a source, and a source of the same name wins. +`pulse:.monitor` still works. + +On Linux the capture includes this player's own playback when it plays to the same sink, +so the server gets its own audio back. The player logs a warning at startup unless `-o` +names a different PulseAudio or PipeWire sink, or is `null` or `stdout`. Loop back a sink the player +does not play to. + ### 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 50ebd66..653160d 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[:]`, `tone`, `null`; see [Stream a microphone or line-in](Advanced-Usage#stream-a-microphone-or-line-in) | 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[:]`, `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/src/audio_source.cpp b/src/audio_source.cpp index f92d201..9fc1df5 100644 --- a/src/audio_source.cpp +++ b/src/audio_source.cpp @@ -66,6 +66,30 @@ constexpr bool HAVE_PIPEWIRE = true; #else constexpr bool HAVE_PIPEWIRE = false; #endif +constexpr bool HAVE_LOOPBACK = HAVE_PULSE || HAVE_PIPEWIRE; + +#if defined(SENDSPIN_CLI_HAVE_PULSE) || defined(SENDSPIN_CLI_HAVE_PIPEWIRE) +/// The loopback source on the first sound server that answers: PipeWire, then PulseAudio. +std::unique_ptr make_loopback_source(const std::string& device, std::string& error) { + std::string tried; +#ifdef SENDSPIN_CLI_HAVE_PIPEWIRE + std::string pipewire_error; + if (PipeWireAudioSource::daemon_answers(pipewire_error)) { + return std::make_unique(device, true); + } + tried += "PipeWire: " + pipewire_error; +#endif +#ifdef SENDSPIN_CLI_HAVE_PULSE + std::string pulse_error; + if (PulseAudioSource::server_answers(pulse_error)) { + return std::make_unique(device, true); + } + tried += (tried.empty() ? "" : "; ") + ("PulseAudio: " + pulse_error); +#endif + error = "--input loopback: no sound server answered (" + tried + ")"; + return nullptr; +} +#endif } // namespace @@ -192,6 +216,9 @@ std::string input_backend_list() { #ifdef SENDSPIN_CLI_HAVE_PORTAUDIO list += ", portaudio"; #endif + if (HAVE_LOOPBACK) { + list += ", loopback"; + } return list; } @@ -279,6 +306,23 @@ bool resolve_input_spec(const std::string& spec, InputSpec& out, std::string& er #endif } + if (prefix == "loopback") { + if (!HAVE_LOOPBACK) { + error = "--input loopback needs the PipeWire or PulseAudio backend, and this build has " + "neither, so --input takes only: " + + input_backend_list(); + return false; + } + if (has_device && rest.empty()) { + error = "--input '" + spec + + "' names no sink -- write --input loopback:, or --input loopback on " + "its own for the default sink"; + return false; + } + out = {SourceBackend::Loopback, rest == "default" ? std::string() : rest}; + return true; + } + if (prefix == "portaudio") { #ifdef SENDSPIN_CLI_HAVE_PORTAUDIO if (has_device && rest.empty()) { @@ -316,6 +360,23 @@ bool input_pcm_is_reachable(const std::string& pcm) { spec.device == pcm; } +bool may_capture_own_playback(const DeviceSpec& output, const std::string& looped_sink) { + switch (output.backend) { + case SinkBackend::Null: + case SinkBackend::Stdout: + return false; + case SinkBackend::Pulse: + case SinkBackend::PipeWire: + return looped_sink.empty() || output.device.empty() || output.device == "default" || + output.device == looped_sink; + case SinkBackend::Alsa: + case SinkBackend::CoreAudio: + case SinkBackend::PortAudio: + return true; + } + return true; +} + std::unique_ptr make_audio_source(const std::string& spec, StreamFormat& format, std::string& error) { InputSpec resolved; @@ -349,6 +410,14 @@ std::unique_ptr make_audio_source(const std::string& spec, StreamFo case SourceBackend::CoreAudio: #ifdef SENDSPIN_CLI_HAVE_COREAUDIO source = std::make_unique(resolved.device); +#endif + break; + case SourceBackend::Loopback: +#if defined(SENDSPIN_CLI_HAVE_PULSE) || defined(SENDSPIN_CLI_HAVE_PIPEWIRE) + source = make_loopback_source(resolved.device, error); + if (!source) { + return nullptr; + } #endif break; case SourceBackend::PortAudio: @@ -401,8 +470,7 @@ void print_capture_devices(std::FILE* out) { PulseAudioSource::list_devices(out); std::fprintf(out, "\nThe name on each source's own line is what follows --input pulse:. --input\n" - "pulse with no source at all follows whichever one the server calls default.\n" - "A .monitor source captures what its sink is playing.\n"); + "pulse with no source at all follows whichever one the server calls default.\n"); #endif #ifdef SENDSPIN_CLI_HAVE_PIPEWIRE @@ -415,6 +483,20 @@ void print_capture_devices(std::FILE* out) { #endif #if defined(SENDSPIN_CLI_HAVE_PULSE) || defined(SENDSPIN_CLI_HAVE_PIPEWIRE) + std::fprintf(out, + "\nLoopback (--input loopback[:]):\n" + "--input loopback captures what this host plays through its default sink, and\n" + "--input loopback: what one sink plays, through PipeWire if it is running,\n" + "else PulseAudio. This player's own playback is captured too if it goes there.\n"); +#ifdef SENDSPIN_CLI_HAVE_PIPEWIRE + std::fprintf(out, "\nPipeWire sinks:\n"); + PipeWireAudioSource::list_sinks(out); +#endif +#ifdef SENDSPIN_CLI_HAVE_PULSE + std::fprintf(out, "\nPulseAudio sinks:\n"); + PulseAudioSource::list_sinks(out); +#endif + std::fprintf(out, "\nA sound server converts, so a capture through one runs at 48000 Hz, 2\n" "channels, 16-bit whatever the hardware behind it captures.\n"); #endif diff --git a/src/audio_source.h b/src/audio_source.h index 2834f18..24dd911 100644 --- a/src/audio_source.h +++ b/src/audio_source.h @@ -28,6 +28,7 @@ #include #include #include +#include #include #include @@ -48,6 +49,12 @@ class AudioSource { /// The device as --input reaches it, e.g. "hw:1,0". virtual std::string name() const = 0; + /// The sink whose output this source captures, empty for the default sink; nullopt for an + /// input. Settled by negotiate(). + virtual std::optional looped_sink() const { + return std::nullopt; + } + /// Narrows `format` to the nearest one the device captures, opening it briefly. /// @return false with `error` set if the device cannot be opened or offers nothing usable. virtual bool negotiate(StreamFormat& format, std::string& error) = 0; @@ -120,6 +127,7 @@ enum class SourceBackend { PipeWire, ///< a PipeWire source node, or the graph's default with no device CoreAudio, ///< a CoreAudio device by index or name, or this host's default input if empty PortAudio, ///< a PortAudio device by index or name, or its startup default input if empty + Loopback, ///< a sink's output on the first sound server that answers; the default if empty }; /// An --input spec resolved into a backend and the device to hand it. @@ -139,6 +147,9 @@ std::string input_backend_list(); /// True if a bare `--input ` reaches the ALSA PCM of that name. 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); + /// 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 06f62b1..81a171a 100644 --- a/src/cli.cpp +++ b/src/cli.cpp @@ -952,6 +952,10 @@ 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) + std::fprintf(out, " --input loopback streams what this host plays, and\n"); + std::fprintf(out, " --input loopback: what one sink plays\n"); +#endif #ifdef SENDSPIN_CLI_HAVE_ALSA std::fprintf(out, " Anything else is an ALSA PCM name: --input default,\n"); std::fprintf(out, " --input hw:1,0. Without --input nothing is captured\n"); diff --git a/src/main.cpp b/src/main.cpp index 868a041..660a136 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -725,6 +725,17 @@ int main(int argc, char* argv[]) { source->name().c_str(), capture_format.sample_rate, static_cast(capture_format.channels), static_cast(capture_format.bit_depth)); + + DeviceSpec output; + std::string output_error; + const std::optional looped = source->looped_sink(); + if (looped && (!resolve_device_spec(opts.device, output, output_error) || + may_capture_own_playback(output, *looped))) { + log_line(LogLevel::WARN, LOG_TAG_AUDIO, + "--input %s may capture what this player plays through -o %s, sending the " + "server its own audio back -- loop back a sink that -o does not play to", + opts.input.c_str(), opts.device.c_str()); + } } // Loaded before the client, since its values must be in place before anything is published. diff --git a/src/pipewire_sink.cpp b/src/pipewire_sink.cpp index 0a7ff3e..8f34324 100644 --- a/src/pipewire_sink.cpp +++ b/src/pipewire_sink.cpp @@ -102,7 +102,7 @@ void registry_global_cb(void* data, uint32_t /*id*/, uint32_t /*permissions*/, c if (description == nullptr) { description = spa_dict_lookup(props, PW_KEY_NODE_NICK); } - walk->nodes.push_back({node_name, (description != nullptr) ? description : ""}); + walk->nodes.push_back({node_name, (description != nullptr) ? description : "", media_class}); } void core_done_cb(void* data, uint32_t id, int seq) { diff --git a/src/pipewire_sink.h b/src/pipewire_sink.h index 1cbb168..796b804 100644 --- a/src/pipewire_sink.h +++ b/src/pipewire_sink.h @@ -98,6 +98,7 @@ class LoopLock { struct PipeWireNode { std::string name; std::string description; + std::string media_class; }; /// Walks the daemon's registry once on a private loop for nodes of any of `media_classes`. diff --git a/src/pipewire_source.cpp b/src/pipewire_source.cpp index 8cb590e..a311515 100644 --- a/src/pipewire_source.cpp +++ b/src/pipewire_source.cpp @@ -23,6 +23,8 @@ #include #include #include +#include +#include #include #include @@ -61,36 +63,21 @@ spa_audio_format spa_format_for(uint8_t bit_depth) { } } -/// Lists the nodes a capture stream can link to. -bool list_source_nodes(std::vector& out, std::string& error, - int timeout_ms = PIPEWIRE_TIMEOUT_S * 1000) { - return pipewire_list_nodes({"Audio/Source", "Audio/Source/Virtual", "Audio/Duplex"}, out, error, - timeout_ms); -} - -} // namespace - -PipeWireAudioSource::PipeWireAudioSource(std::string device) : device_(std::move(device)) {} - -PipeWireAudioSource::~PipeWireAudioSource() { - this->close(); -} - -std::string PipeWireAudioSource::name() const { - return this->device_.empty() ? "pipewire" : "pipewire:" + this->device_; -} +constexpr const char* SINK_CLASS = "Audio/Sink"; -void PipeWireAudioSource::list_devices(std::FILE* out) { +/// Prints the nodes of the given classes, one name per line with its description under it. +void print_nodes(std::FILE* out, std::initializer_list media_classes, + const char* kind) { const PipeWireGuard guard; std::vector nodes; std::string error; - if (!list_source_nodes(nodes, error)) { + if (!pipewire_list_nodes(media_classes, nodes, error)) { std::fprintf(out, " (%s)\n", error.c_str()); return; } if (nodes.empty()) { - std::fprintf(out, " (this graph has no audio source nodes)\n"); + std::fprintf(out, " (this graph has no audio %s nodes)\n", kind); return; } for (const PipeWireNode& node : nodes) { @@ -101,19 +88,67 @@ void PipeWireAudioSource::list_devices(std::FILE* out) { } } -bool PipeWireAudioSource::find_node_(std::string& error, int timeout_ms) const { +} // namespace + +PipeWireAudioSource::PipeWireAudioSource(std::string device, bool loopback) + : device_(std::move(device)), loopback_(loopback), capture_sink_(loopback && device_.empty()) {} + +PipeWireAudioSource::~PipeWireAudioSource() { + this->close(); +} + +std::string PipeWireAudioSource::name() const { + if (this->loopback_) { + return this->device_.empty() ? "loopback" : "loopback:" + this->device_; + } + return this->device_.empty() ? "pipewire" : "pipewire:" + this->device_; +} + +std::optional PipeWireAudioSource::looped_sink() const { + if (!this->capture_sink_) { + return std::nullopt; + } + return this->device_; +} + +void PipeWireAudioSource::list_devices(std::FILE* out) { + print_nodes(out, {"Audio/Source", "Audio/Source/Virtual", "Audio/Duplex"}, "source"); +} + +void PipeWireAudioSource::list_sinks(std::FILE* out) { + print_nodes(out, {SINK_CLASS}, "sink"); +} + +bool PipeWireAudioSource::daemon_answers(std::string& error) { + const PipeWireGuard guard; + std::vector nodes; + return pipewire_list_nodes({}, nodes, error); +} + +bool PipeWireAudioSource::find_node_(std::string& error, int timeout_ms) { std::vector nodes; - if (!list_source_nodes(nodes, error, timeout_ms)) { + const bool listed = + this->loopback_ ? pipewire_list_nodes({SINK_CLASS}, nodes, error, timeout_ms) + : pipewire_list_nodes( + {"Audio/Source", "Audio/Source/Virtual", "Audio/Duplex", SINK_CLASS}, + nodes, error, timeout_ms); + if (!listed) { return false; } - const bool found = this->device_.empty() || - std::any_of(nodes.begin(), nodes.end(), [this](const PipeWireNode& node) { - return node.name == this->device_; - }); - if (!found) { - error = "--input " + this->name() + ": this graph has no audio source node by that name"; + // A source of the name wins over a sink of the same name. + const PipeWireNode* match = nullptr; + for (const PipeWireNode& node : nodes) { + if (node.name == this->device_ && (match == nullptr || match->media_class == SINK_CLASS)) { + match = &node; + } } - return found; + if (match == nullptr) { + error = "--input " + this->name() + ": this graph has no audio " + + (this->loopback_ ? "sink " : "") + "node by that name"; + return false; + } + this->capture_sink_ = match->media_class == SINK_CLASS; + return true; } bool PipeWireAudioSource::negotiate(StreamFormat& format, std::string& error) { @@ -132,8 +167,15 @@ bool PipeWireAudioSource::open(const StreamFormat& format) { cli_log(LogLevel::ERROR, "pipewire: %s", error.c_str()); return false; } - cli_log(LogLevel::INFO, "pipewire: input '%s' open at %u Hz, %u ch, %u-bit", - this->name().c_str(), format.sample_rate, format.channels, format.bit_depth); + std::string captures; + if (this->capture_sink_) { + captures = this->device_.empty() + ? " -- capturing the default sink's output, following it" + : " -- capturing the output of sink '" + this->device_ + "'"; + } + cli_log(LogLevel::INFO, "pipewire: input '%s' open at %u Hz, %u ch, %u-bit%s", + this->name().c_str(), format.sample_rate, format.channels, format.bit_depth, + captures.c_str()); return true; } @@ -181,6 +223,9 @@ bool PipeWireAudioSource::open_(const StreamFormat& format, int timeout_ms, std: // Unplugging the node ends the stream, so it is reopened rather than moved elsewhere. pw_properties_set(props, PW_KEY_NODE_DONT_RECONNECT, "true"); } + if (this->capture_sink_) { + pw_properties_set(props, PW_KEY_STREAM_CAPTURE_SINK, "true"); + } const std::string latency = std::to_string(format.sample_rate * QUANTUM_MS / 1000U) + "/" + std::to_string(format.sample_rate); pw_properties_set(props, PW_KEY_NODE_LATENCY, latency.c_str()); diff --git a/src/pipewire_source.h b/src/pipewire_source.h index 1df9f82..a081ccc 100644 --- a/src/pipewire_source.h +++ b/src/pipewire_source.h @@ -28,19 +28,23 @@ #include #include #include +#include #include namespace sendspin_cli { /// An AudioSource that captures from a PipeWire graph; `--input pipewire:` takes a node name. +/// A sink node captures what that sink plays. /// process() writes the ring and reads the format fields unlocked: change them only while closed. class PipeWireAudioSource final : public AudioSource { public: - /// @param device Source node name, or empty for the graph's default. - explicit PipeWireAudioSource(std::string device); + /// @param device Source or sink node name, or empty for the graph's default. + /// @param loopback With no device, capture the default sink rather than the default source. + explicit PipeWireAudioSource(std::string device, bool loopback = false); ~PipeWireAudioSource() override; std::string name() const override; + std::optional looped_sink() const override; bool negotiate(StreamFormat& format, std::string& error) override; bool open(const StreamFormat& format) override; int read(uint8_t* data, size_t length, uint32_t timeout_ms, int64_t& capture_time_us) override; @@ -49,6 +53,12 @@ class PipeWireAudioSource final : public AudioSource { /// Prints this graph's audio source nodes. static void list_devices(std::FILE* out); + /// Prints this graph's audio sink nodes. + static void list_sinks(std::FILE* out); + + /// True if a PipeWire daemon answers. + static bool daemon_answers(std::string& error); + private: static void stream_state_cb(void* userdata, enum pw_stream_state old, enum pw_stream_state state, const char* error); @@ -57,13 +67,15 @@ class PipeWireAudioSource final : public AudioSource { /// Connects the stream, waiting up to `timeout_ms` for the graph to take it. bool open_(const StreamFormat& format, int timeout_ms, std::string& error); - /// Checks that a daemon answers and has the named node. - bool find_node_(std::string& error, int timeout_ms) const; + /// Checks that a daemon answers and has the named node, and notes whether it is a sink. + bool find_node_(std::string& error, int timeout_ms); /// Declared first so it is destroyed last, after the stream and its loop. PipeWireGuard pw_; std::string device_; ///< empty for the graph's default + bool loopback_; + bool capture_sink_{false}; ///< set by find_node_(); kept across close() pw_thread_loop* loop_{nullptr}; pw_stream* stream_{nullptr}; diff --git a/src/pulse_sink.cpp b/src/pulse_sink.cpp index 6cf6235..26e1c5e 100644 --- a/src/pulse_sink.cpp +++ b/src/pulse_sink.cpp @@ -64,13 +64,6 @@ bool pulse_format_for(uint8_t bits_per_sample, pa_sample_format_t& format) { return false; } -/// One sink as the server describes it. -struct PulseSinkInfo { - std::string name; - std::string description; - pa_sample_spec spec{}; -}; - struct SinkListQuery : PulseQuery { std::vector sinks; }; @@ -92,6 +85,7 @@ void sink_info_cb(pa_context* /*context*/, const pa_sink_info* info, int eol, vo PulseSinkInfo entry; entry.name = (info->name != nullptr) ? info->name : ""; entry.description = (info->description != nullptr) ? info->description : ""; + entry.monitor_source = (info->monitor_source_name != nullptr) ? info->monitor_source_name : ""; entry.spec = info->sample_spec; query->sinks.push_back(std::move(entry)); } @@ -104,8 +98,9 @@ void server_info_cb(pa_context* /*context*/, const pa_server_info* info, void* u query->finish(info != nullptr); } -/// Asks the server for every sink it has. The mainloop lock must NOT be held. -bool list_sinks(PulseConnection& conn, std::vector& out) { +} // namespace + +bool list_sinks(PulseConnection& conn, std::vector& out, int timeout_ms) { SinkListQuery query; query.conn = &conn; pa_operation* op = nullptr; @@ -113,15 +108,14 @@ bool list_sinks(PulseConnection& conn, std::vector& out) { const MainloopLock lock(conn.mainloop()); op = pa_context_get_sink_info_list(conn.context(), sink_info_cb, &query); } - if (!await_query(conn, query, op)) { + if (!await_query(conn, query, op, timeout_ms)) { return false; } out = std::move(query.sinks); return true; } -/// Asks the server which sink it calls default. Empty when it will not say. Lock must NOT be held. -std::string default_sink_name(PulseConnection& conn) { +std::string default_sink_name(PulseConnection& conn, int timeout_ms) { ServerQuery query; query.conn = &conn; pa_operation* op = nullptr; @@ -129,14 +123,12 @@ std::string default_sink_name(PulseConnection& conn) { const MainloopLock lock(conn.mainloop()); op = pa_context_get_server_info(conn.context(), server_info_cb, &query); } - if (!await_query(conn, query, op)) { + if (!await_query(conn, query, op, timeout_ms)) { return ""; } return query.default_sink; } -} // namespace - bool await_query(PulseConnection& conn, PulseQuery& query, pa_operation* op, int timeout_ms) { if (op == nullptr) { return false; diff --git a/src/pulse_sink.h b/src/pulse_sink.h index f129172..6169693 100644 --- a/src/pulse_sink.h +++ b/src/pulse_sink.h @@ -127,6 +127,21 @@ struct PulseQuery { } }; +/// One sink as the server describes it. +struct PulseSinkInfo { + std::string name; + std::string description; + std::string monitor_source; + pa_sample_spec spec{}; +}; + +/// Asks the server for every sink it has. The mainloop lock must NOT be held. +bool list_sinks(PulseConnection& conn, std::vector& out, + int timeout_ms = PULSE_TIMEOUT_MS); + +/// Asks the server which sink it calls default. Empty when it will not say. Lock must NOT be held. +std::string default_sink_name(PulseConnection& conn, int timeout_ms = PULSE_TIMEOUT_MS); + /// Waits for one query, cancelling it on timeout so a late callback cannot touch a dead `query`. /// The mainloop lock must NOT be held. /// @return true if the query completed and the server answered it. diff --git a/src/pulse_source.cpp b/src/pulse_source.cpp index b603c3b..6a36d27 100644 --- a/src/pulse_source.cpp +++ b/src/pulse_source.cpp @@ -58,10 +58,14 @@ pa_sample_format_t pulse_format_for(uint8_t bit_depth) { } } +/// The source name PulseAudio resolves to the default sink's monitor. +constexpr const char* DEFAULT_MONITOR = "@DEFAULT_MONITOR@"; + /// One source as the server describes it. struct PulseSourceInfo { std::string name; std::string description; + std::string monitor_of_sink; ///< empty unless this is a sink's monitor pa_sample_spec spec{}; }; @@ -82,6 +86,9 @@ void source_info_cb(pa_context* /*context*/, const pa_source_info* info, int eol PulseSourceInfo entry; entry.name = (info->name != nullptr) ? info->name : ""; entry.description = (info->description != nullptr) ? info->description : ""; + if (info->monitor_of_sink != PA_INVALID_INDEX && info->monitor_of_sink_name != nullptr) { + entry.monitor_of_sink = info->monitor_of_sink_name; + } entry.spec = info->sample_spec; query->sources.push_back(std::move(entry)); } @@ -125,16 +132,29 @@ std::string default_source_name(PulseConnection& conn) { } // namespace -PulseAudioSource::PulseAudioSource(std::string device) : device_(std::move(device)) {} +PulseAudioSource::PulseAudioSource(std::string device, bool loopback) + : device_(std::move(device)), loopback_(loopback) {} PulseAudioSource::~PulseAudioSource() { this->close(); } std::string PulseAudioSource::name() const { + if (this->loopback_) { + return this->device_.empty() ? "loopback" : "loopback:" + this->device_; + } return this->device_.empty() ? "pulse" : "pulse:" + this->device_; } +std::optional PulseAudioSource::looped_sink() const { + return this->looped_sink_; +} + +bool PulseAudioSource::server_answers(std::string& error) { + PulseConnection conn; + return conn.connect(error); +} + void PulseAudioSource::list_devices(std::FILE* out) { PulseConnection conn; std::string error; @@ -164,20 +184,80 @@ void PulseAudioSource::list_devices(std::FILE* out) { } } -bool PulseAudioSource::find_source_(std::string& error, int timeout_ms) { +void PulseAudioSource::list_sinks(std::FILE* out) { + PulseConnection conn; + std::string error; + if (!conn.connect(error)) { + std::fprintf(out, " (%s)\n", error.c_str()); + return; + } + + std::vector sinks; + if (!sendspin_cli::list_sinks(conn, sinks)) { + std::fprintf(out, " (cannot list the sinks on %s)\n", conn.server_name().c_str()); + return; + } + if (sinks.empty()) { + std::fprintf(out, " (%s has no sinks)\n", conn.server_name().c_str()); + return; + } + + const std::string fallback = default_sink_name(conn); + for (const PulseSinkInfo& sink : sinks) { + std::fprintf(out, " %s%s\n", sink.name.c_str(), + (sink.name == fallback) ? " (server default)" : ""); + std::fprintf(out, " %s\n", sink.description.c_str()); + } +} + +bool PulseAudioSource::resolve_source_(std::string& error, int timeout_ms) { + this->record_from_.clear(); + this->looped_sink_.reset(); + this->looped_sink_name_.clear(); + if (this->device_.empty()) { + if (this->loopback_) { + this->record_from_ = DEFAULT_MONITOR; + this->looped_sink_ = ""; + this->looped_sink_name_ = default_sink_name(this->conn_, timeout_ms); + } + return true; + } + std::vector sources; if (!list_sources(this->conn_, sources, timeout_ms)) { error = "cannot list the sources on " + this->conn_.server_name(); return false; } - const bool found = - std::any_of(sources.begin(), sources.end(), - [this](const PulseSourceInfo& source) { return source.name == this->device_; }); - if (!found) { + // loopback: takes a sink or a sink's monitor, never an input. + const auto source = + std::find_if(sources.begin(), sources.end(), [this](const PulseSourceInfo& info) { + return info.name == this->device_ && + (!this->loopback_ || !info.monitor_of_sink.empty()); + }); + if (source != sources.end()) { + this->record_from_ = source->name; + if (!source->monitor_of_sink.empty()) { + this->looped_sink_ = source->monitor_of_sink; + } + return true; + } + + std::vector sinks; + if (!sendspin_cli::list_sinks(this->conn_, sinks, timeout_ms)) { + error = "cannot list the sinks on " + this->conn_.server_name(); + return false; + } + const auto sink = std::find_if(sinks.begin(), sinks.end(), [this](const PulseSinkInfo& info) { + return info.name == this->device_; + }); + if (sink == sinks.end() || sink->monitor_source.empty()) { error = "--input " + this->name() + ": " + this->conn_.server_name() + - " has no source by that name"; + (this->loopback_ ? " has no sink" : " has no source or sink") + " by that name"; + return false; } - return found; + this->record_from_ = sink->monitor_source; + this->looped_sink_ = sink->name; + return true; } bool PulseAudioSource::negotiate(StreamFormat& format, std::string& error) { @@ -196,9 +276,18 @@ bool PulseAudioSource::open(const StreamFormat& format) { cli_log(LogLevel::ERROR, "pulse: %s", error.c_str()); return false; } - cli_log(LogLevel::INFO, "pulse: input '%s' open on %s at %u Hz, %u ch, %u-bit", + std::string captures; + if (this->looped_sink_ && !this->looped_sink_->empty()) { + captures = " -- capturing the monitor of sink '" + *this->looped_sink_ + "'"; + } else if (this->looped_sink_) { + captures = " -- capturing the monitor of the default sink"; + if (!this->looped_sink_name_.empty()) { + captures += " '" + this->looped_sink_name_ + "'"; + } + } + cli_log(LogLevel::INFO, "pulse: input '%s' open on %s at %u Hz, %u ch, %u-bit%s", this->name().c_str(), this->conn_.server_name().c_str(), format.sample_rate, - format.channels, format.bit_depth); + format.channels, format.bit_depth, captures.c_str()); return true; } @@ -221,7 +310,7 @@ bool PulseAudioSource::open_(const StreamFormat& format, int timeout_ms, std::st return false; } // pipewire-pulse records from the default when the named source is missing. - if (!this->device_.empty() && !this->find_source_(error, timeout_ms)) { + if (!this->resolve_source_(error, timeout_ms)) { return false; } @@ -241,7 +330,7 @@ bool PulseAudioSource::open_(const StreamFormat& format, int timeout_ms, std::st // Unplugging the source ends the stream, so it is reopened rather than moved elsewhere. flags |= PA_STREAM_DONT_MOVE; } - const char* source = this->device_.empty() ? nullptr : this->device_.c_str(); + const char* source = this->record_from_.empty() ? nullptr : this->record_from_.c_str(); int err = -1; { diff --git a/src/pulse_source.h b/src/pulse_source.h index 0ad903c..1f0758d 100644 --- a/src/pulse_source.h +++ b/src/pulse_source.h @@ -25,18 +25,23 @@ #include #include #include +#include #include namespace sendspin_cli { -/// An AudioSource that captures from a PulseAudio server; `--input pulse:` takes a source name. +/// An AudioSource that captures from a PulseAudio server; `--input pulse:` takes a source name, +/// or failing that a sink's, whose monitor it captures. class PulseAudioSource final : public AudioSource { public: - /// @param device Source name, or empty for the server's default. - explicit PulseAudioSource(std::string device); + /// @param device Source or sink name, or empty for the server's default. + /// @param loopback With no device, capture the default sink's monitor rather than the default + /// source. + explicit PulseAudioSource(std::string device, bool loopback = false); ~PulseAudioSource() override; std::string name() const override; + std::optional looped_sink() const override; bool negotiate(StreamFormat& format, std::string& error) override; bool open(const StreamFormat& format) override; int read(uint8_t* data, size_t length, uint32_t timeout_ms, int64_t& capture_time_us) override; @@ -45,6 +50,12 @@ class PulseAudioSource final : public AudioSource { /// Prints this server's sources, with the default marked. static void list_devices(std::FILE* out); + /// Prints this server's sinks, with the default marked. + static void list_sinks(std::FILE* out); + + /// True if a PulseAudio server answers. + static bool server_answers(std::string& error); + private: static void stream_state_cb(pa_stream* stream, void* userdata); static void stream_read_cb(pa_stream* stream, size_t nbytes, void* userdata); @@ -52,8 +63,9 @@ class PulseAudioSource final : public AudioSource { /// Connects if need be and opens the record stream, each wait bounded by `timeout_ms`. bool open_(const StreamFormat& format, int timeout_ms, std::string& error); - /// Checks that the connected server has the named source. - bool find_source_(std::string& error, int timeout_ms); + /// Settles what to record from on the connected server: a source of the name, else the + /// monitor of a sink of the name. + bool resolve_source_(std::string& error, int timeout_ms); /// Copies out what the server has queued, without waiting. /// @return Bytes copied, 0 if nothing is queued, negative once the stream is lost. @@ -63,6 +75,12 @@ class PulseAudioSource final : public AudioSource { PulseConnection conn_; std::string device_; ///< empty for the server's default + bool loopback_; + + /// Set by resolve_source_(); kept across close(). + std::string record_from_; ///< empty for the server's default source + std::optional looped_sink_; + std::string looped_sink_name_; ///< the default sink's own name when looped_sink_ is empty pa_stream* stream_{nullptr}; uint32_t rate_{0}; diff --git a/tests/audio_source_test.cpp b/tests/audio_source_test.cpp index 3552145..cd7e193 100644 --- a/tests/audio_source_test.cpp +++ b/tests/audio_source_test.cpp @@ -273,6 +273,86 @@ TEST(ResolveInputSpec, WithoutPipeWireItsNameIsNotInThisBuild) { } #endif +#if defined(SENDSPIN_CLI_HAVE_PULSE) || defined(SENDSPIN_CLI_HAVE_PIPEWIRE) +TEST(ResolveInputSpec, LoopbackTakesASinkOrTheDefault) { + for (const char* spec : {"loopback", "loopback:default"}) { + EXPECT_EQ(resolved(spec).backend, SourceBackend::Loopback) << spec; + EXPECT_TRUE(resolved(spec).device.empty()) << spec; + } + EXPECT_EQ(resolved("loopback:alsa_output.pci-0000_00_1f.3.analog-stereo").device, + "alsa_output.pci-0000_00_1f.3.analog-stereo"); + EXPECT_EQ(resolved("loopback:a:b").device, "a:b"); + EXPECT_NE(input_backend_list().find("loopback"), std::string::npos); +} + +TEST(ResolveInputSpec, LoopbackPrefixWithNothingAfterTheColonIsRejected) { + EXPECT_NE(rejected("loopback:").find("--input loopback on its own"), std::string::npos); +} + +TEST(ResolveInputSpec, TheBackendFormsAreUnchangedByLoopback) { +#ifdef SENDSPIN_CLI_HAVE_PULSE + EXPECT_EQ(resolved("pulse:default").backend, SourceBackend::Pulse); + EXPECT_TRUE(resolved("pulse:default").device.empty()); + EXPECT_EQ(resolved("pulse:alsa_output.usb.analog-stereo.monitor").device, + "alsa_output.usb.analog-stereo.monitor"); +#endif +#ifdef SENDSPIN_CLI_HAVE_PIPEWIRE + EXPECT_EQ(resolved("pipewire:alsa_output.usb.analog-stereo").backend, SourceBackend::PipeWire); +#endif +} + +TEST(MakeAudioSource, LoopbackWithNoSoundServerNamesEveryOneTried) { + const ScopedEnv remote("PIPEWIRE_REMOTE", "/nonexistent/sendspin-cli-test"); + const ScopedEnv server("PULSE_SERVER", "unix:/nonexistent/sendspin-cli-test"); + StreamFormat format = STEREO_16; + std::string error; + EXPECT_EQ(make_audio_source("loopback", format, error), nullptr); +#ifdef SENDSPIN_CLI_HAVE_PIPEWIRE + EXPECT_NE(error.find("PipeWire"), std::string::npos) << error; +#endif +#ifdef SENDSPIN_CLI_HAVE_PULSE + EXPECT_NE(error.find("PulseAudio"), std::string::npos) << error; +#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(input_backend_list()), std::string::npos) << spec; + } + EXPECT_EQ(input_backend_list().find("loopback"), std::string::npos); +} +#endif + +// When a loopback might hear this player's own output + +TEST(MayCaptureOwnPlayback, NotWhenNothingIsPlayedToASink) { + EXPECT_FALSE(may_capture_own_playback({SinkBackend::Null, ""}, "")); + EXPECT_FALSE(may_capture_own_playback({SinkBackend::Stdout, ""}, "speakers")); +} + +TEST(MayCaptureOwnPlayback, AlwaysForTheDefaultSink) { + for (const SinkBackend backend : + {SinkBackend::Pulse, SinkBackend::PipeWire, SinkBackend::Alsa, SinkBackend::PortAudio}) { + EXPECT_TRUE(may_capture_own_playback({backend, "speakers"}, "")); + } +} + +TEST(MayCaptureOwnPlayback, NotForANamedSinkTheOutputProvablyAvoids) { + EXPECT_FALSE(may_capture_own_playback({SinkBackend::Pulse, "headphones"}, "speakers")); + EXPECT_FALSE(may_capture_own_playback({SinkBackend::PipeWire, "headphones"}, "speakers")); +} + +TEST(MayCaptureOwnPlayback, WhenTheOutputCannotBeShownToGoElsewhere) { + EXPECT_TRUE(may_capture_own_playback({SinkBackend::Pulse, "speakers"}, "speakers")); + EXPECT_TRUE(may_capture_own_playback({SinkBackend::PipeWire, ""}, "speakers")); + EXPECT_TRUE(may_capture_own_playback({SinkBackend::Pulse, "default"}, "speakers")); + EXPECT_TRUE(may_capture_own_playback({SinkBackend::Alsa, "hw:1,0"}, "speakers")); + EXPECT_TRUE(may_capture_own_playback({SinkBackend::CoreAudio, ""}, "speakers")); + EXPECT_TRUE(may_capture_own_playback({SinkBackend::PortAudio, "2"}, "speakers")); +} + TEST(SettleServerCaptureFormat, KeepsWhatAStreamCanCarry) { for (const uint8_t depth : CAPTURE_BIT_DEPTHS) { StreamFormat format{44100, 1, depth};