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
4 changes: 2 additions & 2 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -318,13 +318,13 @@ if(SENDSPIN_CLI_PORTAUDIO_ENABLED)
endif()

if(SENDSPIN_CLI_PULSE_ENABLED)
target_sources(sendspin-cli-core PRIVATE src/pulse_sink.cpp)
target_sources(sendspin-cli-core PRIVATE src/pulse_sink.cpp src/pulse_source.cpp)
target_link_libraries(sendspin-cli-core PUBLIC PkgConfig::PULSE)
target_compile_definitions(sendspin-cli-core PUBLIC SENDSPIN_CLI_HAVE_PULSE)
endif()

if(SENDSPIN_CLI_PIPEWIRE_ENABLED)
target_sources(sendspin-cli-core PRIVATE src/pipewire_sink.cpp)
target_sources(sendspin-cli-core PRIVATE src/pipewire_sink.cpp src/pipewire_source.cpp)
target_link_libraries(sendspin-cli-core PUBLIC PkgConfig::PIPEWIRE)
target_compile_definitions(sendspin-cli-core PUBLIC SENDSPIN_CLI_HAVE_PIPEWIRE)
endif()
Expand Down
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` or `hw:1,0`, 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>` | A capture device to stream to the server: a microphone or line-in, e.g. `default`, `hw:1,0`, `pulse` or `pipewire:<node>`, 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). |
| `--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
47 changes: 31 additions & 16 deletions docs/wiki/Advanced-Usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ streams that audio to the server, for a turntable on a line-in or a microphone i
```bash
sendspin-cli -n den --input default
sendspin-cli -n den --input hw:1,0
sendspin-cli -n den --input pipewire
sendspin-cli -n den --input pulse:alsa_input.usb-mic.analog-stereo
```

On macOS:
Expand All @@ -73,31 +75,44 @@ Nothing is captured until the server starts the source, and the device is releas
when the server stops it. Without `--input` the player does not offer the source role at
all.

`sendspin-cli -l` lists capture devices in their own section, under the outputs, with the
rates, formats and channel counts each takes. `--input` reads its argument the way `-o`
does: `alsa:<device>`, or a bare ALSA name such as `default`, `hw:1,0` or `plughw:1,0`.
On macOS it is `coreaudio` for the system default input, which is followed if it moves, or
`coreaudio:<index|name>` for one device from `-l`'s list.
Two names 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.
`sendspin-cli -l` lists capture devices in their own section, under the outputs: ALSA
PCMs with the rates, formats and channel counts each takes, then the PulseAudio sources
and PipeWire source nodes, or on macOS the CoreAudio input devices. `--input` reads its
argument the way `-o` does:

- `alsa:<device>`, or a bare ALSA name such as `default`, `hw:1,0` or `plughw:1,0`.
- `pulse:<source>` for a PulseAudio source, or `pulse` alone for the server's default.
- `pipewire:<node>` for a PipeWire source node, or `pipewire` alone for the graph's
default. `pulse:default` and `pipewire:default` mean the same as the bare names.
- On macOS, `coreaudio` for the system default input, which is followed if it moves, or
`coreaudio:<index|name>` for one device from `-l`'s list.
- `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.

On a Linux desktop, prefer `pulse` or `pipewire` to a `hw:` name: the sound server shares
the card with other programs, where a `hw:` device is held exclusively.

The capture format is chosen once at startup: 48000 Hz, stereo, 16-bit if the device
takes it, otherwise the nearest it does take — a mono microphone is sent as mono. On
macOS the rate is the one the device is currently set to, because CoreAudio does not
resample capture; change it in Audio MIDI Setup before starting. The
takes it, otherwise the nearest it does take — a mono microphone is sent as mono. A sound
server converts, so a capture through `pulse` or `pipewire` always runs at 48000 Hz,
stereo, 16-bit. On macOS the rate is the one the device is currently set to, because
CoreAudio does not resample capture; change it in Audio MIDI Setup before starting. The
startup log and `sendspin-cli status` both show what was chosen:

```text
input: hw:1,0 (48000 Hz / 2 ch / 16-bit), streaming
```

A device that cannot be opened — a wrong name, or a card another program holds — stops
the player at startup with an error naming it. A device unplugged mid-stream is logged
and reopened when it comes back; playback is not affected.
A device that cannot be opened — a wrong name, a card another program holds, or a sound
server that is not running — stops the player at startup with an error naming it. A
device unplugged mid-stream, or a sound server that restarts, is logged and reopened when
it comes back; playback is not affected. A named PulseAudio source or PipeWire node is
never swapped for the default while it is away.

Capture through PulseAudio, PipeWire or PortAudio is not available yet, so capturing from
a sound card needs ALSA on Linux or CoreAudio on macOS; `tone` and `null` work in any
build. ALSA's own `pulse` and `pipewire` PCMs still work: `--input alsa:pulse`.
Capture through PortAudio is not available yet, so capturing from a sound card needs
ALSA, PulseAudio or PipeWire on Linux, or CoreAudio on macOS; `tone` and `null` work in
any build. A build without the PulseAudio or PipeWire backend can still reach that server
through ALSA's own PCMs: `--input alsa:pulse`, `--input alsa:pipewire`.

### Microphone permission on macOS

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>`, `coreaudio[:<device>]` on macOS, `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, `tone`, `null`; see [Stream a microphone or line-in](Advanced-Usage#stream-a-microphone-or-line-in) | 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
1 change: 1 addition & 0 deletions packaging/sendspin-cli.conf.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
#output = default

# Capture device to stream to the server (a microphone or line-in); unset captures nothing.
# pulse or pipewire captures the sound server's default source; -l lists the named ones.
#input = hw:1,0

# Audio the output backend keeps buffered, 10-2000 ms. Raise it on a device that stutters.
Expand Down
108 changes: 94 additions & 14 deletions src/audio_source.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@
#ifdef SENDSPIN_CLI_HAVE_ALSA
#include "alsa_source.h"
#endif
#ifdef SENDSPIN_CLI_HAVE_PIPEWIRE
#include "pipewire_source.h"
#endif
#ifdef SENDSPIN_CLI_HAVE_PULSE
#include "pulse_source.h"
#endif
#ifdef SENDSPIN_CLI_HAVE_COREAUDIO
#include "coreaudio_source.h"
#endif
Expand Down Expand Up @@ -48,7 +54,18 @@ constexpr uint32_t READ_TIMEOUT_MS = 100;
constexpr int64_t RATE_WINDOW_MS = 1000;

/// Output backends --input knows by name but cannot capture through.
constexpr const char* UNSUPPORTED_BACKENDS[] = {"portaudio", "pulse", "pipewire"};
constexpr const char* UNSUPPORTED_BACKENDS[] = {"portaudio"};

#ifdef SENDSPIN_CLI_HAVE_PULSE
constexpr bool HAVE_PULSE = true;
#else
constexpr bool HAVE_PULSE = false;
#endif
#ifdef SENDSPIN_CLI_HAVE_PIPEWIRE
constexpr bool HAVE_PIPEWIRE = true;
#else
constexpr bool HAVE_PIPEWIRE = false;
#endif

} // namespace

Expand Down Expand Up @@ -163,12 +180,31 @@ std::string input_backend_list() {
#ifdef SENDSPIN_CLI_HAVE_ALSA
list += ", alsa";
#endif
if (HAVE_PULSE) {
list += ", pulse";
}
if (HAVE_PIPEWIRE) {
list += ", pipewire";
}
#ifdef SENDSPIN_CLI_HAVE_COREAUDIO
list += ", coreaudio";
#endif
return list;
}

void settle_server_capture_format(StreamFormat& format) {
if (std::find(CAPTURE_BIT_DEPTHS.begin(), CAPTURE_BIT_DEPTHS.end(), format.bit_depth) ==
CAPTURE_BIT_DEPTHS.end()) {
format.bit_depth = CAPTURE_BIT_DEPTHS.front();
}
if (format.channels == 0) {
format.channels = 2;
}
if (format.sample_rate == 0) {
format.sample_rate = 48000;
}
}

bool resolve_input_spec(const std::string& spec, InputSpec& out, std::string& error) {
if (spec.empty()) {
error = "empty input device -- run with -l to list what this build has";
Expand Down Expand Up @@ -207,6 +243,21 @@ bool resolve_input_spec(const std::string& spec, InputSpec& out, std::string& er
#endif
}

if (prefix == "pulse" || prefix == "pipewire") {
const bool pulse = prefix == "pulse";
if (!(pulse ? HAVE_PULSE : HAVE_PIPEWIRE)) {
error = std::string("the ") + (pulse ? "PulseAudio" : "PipeWire") +
" backend is not in this build, so --input takes only: " + input_backend_list();
#ifdef SENDSPIN_CLI_HAVE_ALSA
error += ". ALSA's plugin PCM of that name still works: --input alsa:" + prefix;
#endif
return false;
}
out = {pulse ? SourceBackend::Pulse : SourceBackend::PipeWire,
rest == "default" ? std::string() : rest};
return true;
}

if (prefix == "coreaudio") {
#ifdef SENDSPIN_CLI_HAVE_COREAUDIO
if (has_device && rest.empty()) {
Expand All @@ -226,17 +277,11 @@ bool resolve_input_spec(const std::string& spec, InputSpec& out, std::string& er
}

for (const char* name : UNSUPPORTED_BACKENDS) {
if (prefix != name) {
continue;
}
error = "capturing through the " + prefix +
" backend is not supported yet -- --input takes: " + input_backend_list();
#ifdef SENDSPIN_CLI_HAVE_ALSA
if (prefix == "pulse" || prefix == "pipewire") {
error += ". ALSA's plugin PCM of that name still works: --input alsa:" + prefix;
if (prefix == name) {
error = "capturing through the " + prefix +
" backend is not supported yet -- --input takes: " + input_backend_list();
return false;
}
#endif
return false;
}

#ifdef SENDSPIN_CLI_HAVE_ALSA
Expand Down Expand Up @@ -277,6 +322,16 @@ std::unique_ptr<AudioSource> make_audio_source(const std::string& spec, StreamFo
case SourceBackend::Alsa:
#ifdef SENDSPIN_CLI_HAVE_ALSA
source = std::make_unique<AlsaAudioSource>(resolved.device);
#endif
break;
case SourceBackend::Pulse:
#ifdef SENDSPIN_CLI_HAVE_PULSE
source = std::make_unique<PulseAudioSource>(resolved.device);
#endif
break;
case SourceBackend::PipeWire:
#ifdef SENDSPIN_CLI_HAVE_PIPEWIRE
source = std::make_unique<PipeWireAudioSource>(resolved.device);
#endif
break;
case SourceBackend::CoreAudio:
Expand Down Expand Up @@ -320,6 +375,30 @@ void print_capture_devices(std::FILE* out) {
"plug-style PCM -- default, plughw: -- converts, so it takes the preferred\n"
"format whatever the hardware behind it captures.\n");
#endif

#ifdef SENDSPIN_CLI_HAVE_PULSE
std::fprintf(out, "\nPulseAudio sources on this host (--input pulse:<source>):\n");
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");
#endif

#ifdef SENDSPIN_CLI_HAVE_PIPEWIRE
std::fprintf(out, "\nPipeWire audio source nodes on this host (--input pipewire:<node>):\n");
PipeWireAudioSource::list_devices(out);
std::fprintf(out,
"\nThe name on each node's own line is what follows --input pipewire:. --input\n"
"pipewire with no node at all captures wherever the graph routes a capture\n"
"stream.\n");
#endif

#if defined(SENDSPIN_CLI_HAVE_PULSE) || defined(SENDSPIN_CLI_HAVE_PIPEWIRE)
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

#ifdef SENDSPIN_CLI_HAVE_COREAUDIO
std::fprintf(out, "\nCoreAudio input devices on this host (--input coreaudio:<index|name>):\n");
CoreAudioSource::list_devices(out);
Expand All @@ -329,9 +408,10 @@ void print_capture_devices(std::FILE* out) {
"16-bit. Audio MIDI Setup changes the rate. macOS asks for microphone access\n"
"the first time --input opens a device.\n");
#endif
#if !defined(SENDSPIN_CLI_HAVE_ALSA) && !defined(SENDSPIN_CLI_HAVE_COREAUDIO)
std::fprintf(out, "\nThis build has no ALSA backend, so there is no sound card to capture\n"
"from here.\n");
#if !defined(SENDSPIN_CLI_HAVE_ALSA) && !defined(SENDSPIN_CLI_HAVE_PULSE) && \
!defined(SENDSPIN_CLI_HAVE_PIPEWIRE) && !defined(SENDSPIN_CLI_HAVE_COREAUDIO)
std::fprintf(out, "\nThis build has no ALSA, PulseAudio, PipeWire or CoreAudio backend, so\n"
"there is no sound card to capture from here.\n");
#endif
}

Expand Down
9 changes: 7 additions & 2 deletions src/audio_source.h
Original file line number Diff line number Diff line change
Expand Up @@ -116,26 +116,31 @@ enum class SourceBackend {
Null, ///< silence; needs no device
Tone, ///< a test tone; needs no device
Alsa, ///< an ALSA capture PCM, named by InputSpec::device
Pulse, ///< a PulseAudio source, or the server's default with no device
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
};

/// An --input spec resolved into a backend and the device to hand it.
struct InputSpec {
SourceBackend backend{SourceBackend::Null};
std::string device; ///< empty for the device-less sources and CoreAudio's default
std::string device; ///< empty for the device-less sources and a backend's default
};

/// Resolves an --input spec without opening anything, by -o's rules: a bare backend name,
/// `<backend>:<device>` split on the first colon, or else an ALSA PCM name.
/// @return true if `spec` named something this build can capture from.
bool resolve_input_spec(const std::string& spec, InputSpec& out, std::string& error);

/// The backend prefixes --input has in this build, e.g. "null, tone, alsa".
/// The backend prefixes --input has in this build, e.g. "null, tone, alsa, pulse".
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);

/// Settles `format` for a sound server, which converts: only a format no stream can carry changes.
void settle_server_capture_format(StreamFormat& format);

/// Builds the source named by --input and negotiates its capture format.
/// @param format In: the format to prefer. Out: the one the device will capture.
/// @return The source, or nullptr with `error` set.
Expand Down
Loading