Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ sendspin-cli -n den --input default
| `-n, --name <name>` | The friendly name a server displays. Defaults to this host's name. |
| `-o, --output <device>` | Which sound card to play through. `-l` lists this host's devices and what they accept. |
| `--audio-format <codec:rate:depth:channels>[,...]` | 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 <device>` | A capture device to stream to the server: a microphone or line-in, e.g. `default`, `hw:1,0`, `pulse`, `pipewire:<node>` 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 <device>` | Audio to stream to the server: a microphone or line-in, e.g. `default`, `hw:1,0`, `pulse`, `pipewire:<node>` 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 <host[:port]>` | Connect out to a server rather than waiting to be discovered, or `mdns:[<name>]` to discover one over mDNS. Turns off the mDNS advertisement. |
Expand Down
23 changes: 23 additions & 0 deletions docs/wiki/Advanced-Usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ argument the way `-o` does:
`coreaudio:<index|name>` for one device from `-l`'s list.
- `portaudio` for PortAudio's default input as of startup, or `portaudio:<index|name>`
for one device from `-l`'s list, in a build with the PortAudio backend.
- `loopback` or `loopback:<sink>` 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.

Expand Down Expand Up @@ -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:<sink>` 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:<sink>` and
`pulse:<sink>` capture a sink as well as a source, and a source of the same name wins.
`pulse:<sink>.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
Expand Down
2 changes: 1 addition & 1 deletion docs/wiki/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ already does that.
| `product-name` | `--product-name` | the product name `client/hello` reports | `sendspin-cli` |
| `server` | `-s`, `--server` | `<host>[:<port>]`, a `ws://` URL, or `mdns:[<name>]` | 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:<device>`, `pulse[:<source>]`, `pipewire[:<node>]`, `coreaudio[:<device>]` on macOS, `portaudio[:<device>]`, `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:<device>`, `pulse[:<source>]`, `pipewire[:<node>]`, `coreaudio[:<device>]` on macOS, `portaudio[:<device>]`, `loopback[:<sink>]`, `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` |
Expand Down
86 changes: 84 additions & 2 deletions src/audio_source.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<AudioSource> 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<PipeWireAudioSource>(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<PulseAudioSource>(device, true);
}
tried += (tried.empty() ? "" : "; ") + ("PulseAudio: " + pulse_error);
#endif
error = "--input loopback: no sound server answered (" + tried + ")";
return nullptr;
}
#endif

} // namespace

Expand Down Expand Up @@ -192,6 +216,9 @@ std::string input_backend_list() {
#ifdef SENDSPIN_CLI_HAVE_PORTAUDIO
list += ", portaudio";
#endif
if (HAVE_LOOPBACK) {
list += ", loopback";
}
return list;
}

Expand Down Expand Up @@ -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:<sink>, 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()) {
Expand Down Expand Up @@ -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;
Comment thread
chrisuthe marked this conversation as resolved.
case SinkBackend::Alsa:
case SinkBackend::CoreAudio:
case SinkBackend::PortAudio:
return true;
}
return true;
}

std::unique_ptr<AudioSource> make_audio_source(const std::string& spec, StreamFormat& format,
std::string& error) {
InputSpec resolved;
Expand Down Expand Up @@ -349,6 +410,14 @@ std::unique_ptr<AudioSource> make_audio_source(const std::string& spec, StreamFo
case SourceBackend::CoreAudio:
#ifdef SENDSPIN_CLI_HAVE_COREAUDIO
source = std::make_unique<CoreAudioSource>(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:
Expand Down Expand Up @@ -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
Expand All @@ -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[:<sink>]):\n"
"--input loopback captures what this host plays through its default sink, and\n"
"--input loopback:<sink> 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
Expand Down
11 changes: 11 additions & 0 deletions src/audio_source.h
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
#include <functional>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <thread>

Expand All @@ -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<std::string> 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;
Expand Down Expand Up @@ -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.
Expand All @@ -139,6 +147,9 @@ std::string input_backend_list();
/// True if a bare `--input <pcm>` 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);

Expand Down
4 changes: 4 additions & 0 deletions src/cli.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 <backend> 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:<sink> 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");
Expand Down
11 changes: 11 additions & 0 deletions src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -725,6 +725,17 @@ int main(int argc, char* argv[]) {
source->name().c_str(), capture_format.sample_rate,
static_cast<unsigned>(capture_format.channels),
static_cast<unsigned>(capture_format.bit_depth));

DeviceSpec output;
std::string output_error;
const std::optional<std::string> 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.
Expand Down
2 changes: 1 addition & 1 deletion src/pipewire_sink.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions src/pipewire_sink.h
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
Loading
Loading