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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ sendspin-cli -n den --input default
| `--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>` | 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 (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. |
| `--fixed-volume` | Play at full volume, unmuted, and let the server set neither — for a line-out into an amplifier whose level is set there. |
| `--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
1 change: 1 addition & 0 deletions docs/wiki/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ already does that.
| `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` |
| `allow-unpaired` | `--allow-unpaired` | `true`/`false` — let a server that has not paired with this player play on it | `false` |
| `fixed-volume` | `--fixed-volume` | `true`/`false` — play at full volume, unmuted, and offer the server neither volume nor mute, for an output whose level is set elsewhere (a line-out into an amplifier). A remembered volume is kept but ignored, and returns once the key is dropped | `false` |
| `pairing-code` | `--pairing-code` | exactly 8 digits — a static code a server can pair with, confirmed each time with `sendspin-cli pair confirm`; replaces the 6-digit code the player otherwise shows. See [Pairing a Player](Pairing-a-Player#3-static-code) | none |
| `no-mdns` | `--no-mdns` | `true`/`false` — do not advertise `_sendspin._tcp` | `false` |
| `mdns-name` | `--mdns-name` | the instance label to advertise, when it should differ from `name` | `name` |
Expand Down
5 changes: 4 additions & 1 deletion docs/wiki/Controlling-the-Player.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,10 @@ above `output:` says so, and appears whenever a server is connected.
`pairing code` or `pairing window` line follows it; see [Pairing a Player](Pairing-a-Player).
- **`player volume` is the gain this box's output is applying**, and says
`(default; no server has set it)` until a server sends a volume command — which is how you
tell "nobody has set this" from a server that chose full output.
tell "nobody has set this" from a server that chose full output. Under `--fixed-volume` it
reads `100 (fixed; the server cannot set it)`: the output plays at full gain, unmuted, the
server is offered neither volume nor mute, and a volume remembered from an earlier run is
kept but ignored until the flag is dropped. `vol` and `mute` still address the group.
- **`state` and `stream` are different facts.** `state` is the *group's* transport state.
`stream` is whether audio is arriving at *this* endpoint — a player dropped from the group
loses it while the group plays on. A `stream: receiving` line with **no format after the
Expand Down
4 changes: 4 additions & 0 deletions packaging/sendspin-cli.conf.example
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@
# server can connect but not play.
#allow-unpaired = true

# Play at full volume, unmuted, and let the server set neither: for an output whose level is
# set elsewhere, such as a line-out into an amplifier. A remembered volume returns once dropped.
#fixed-volume = true

# A static pairing code: exactly 8 digits, chosen at random for this player. Every attempt
# with it waits for 'sendspin-cli pair confirm'. Unset, the player shows a fresh 6-digit code.
#pairing-code = 48151623
Expand Down
1 change: 1 addition & 0 deletions src/audio_sink.h
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ enum class VolumeSource {
SinkDefault, ///< nothing has set it: DEFAULT_SINK_VOLUME, unmuted
Restored, ///< read back from the state store at startup
Server, ///< a server sent a volume or mute command this run
Fixed, ///< --fixed-volume: full gain, unmuted, and no server may change it
};

/// The parameters configure() takes, as one value.
Expand Down
20 changes: 19 additions & 1 deletion src/cli.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ enum LongOnly {
OPT_ALLOW_UNPAIRED,
OPT_INPUT,
OPT_PAIRING_CODE,
OPT_FIXED_VOLUME,
};

/// An option a config file may set: its key (the long flag name) and how diagnostics name it.
Expand Down Expand Up @@ -109,6 +110,7 @@ const std::vector<SettableOption>& settable_options() {
{Opt::AudioFormat, "audio-format", "--audio-format"},
{Opt::AllowUnpaired, "allow-unpaired", "--allow-unpaired"},
{Opt::PairingCode, "pairing-code", "--pairing-code"},
{Opt::FixedVolume, "fixed-volume", "--fixed-volume"},
};
return table;
}
Expand Down Expand Up @@ -407,6 +409,12 @@ bool apply_option(const SettableOption& option, const std::string& value, Option
return false;
}
break;
case Opt::FixedVolume:
if (!parse_bool(value, out.fixed_volume)) {
error = "invalid --fixed-volume '" + value + "' -- expected true or false";
return false;
}
break;
case Opt::PairingCode:
// The value is a credential, so the message does not repeat it.
if (value.size() != 8 || value.find_first_not_of("0123456789") != std::string::npos) {
Expand Down Expand Up @@ -571,6 +579,7 @@ bool parse_options(int argc, char* argv[], Options& out, std::FILE* err) {
{"audio-format", required_argument, nullptr, OPT_AUDIO_FORMAT},
{"allow-unpaired", no_argument, nullptr, OPT_ALLOW_UNPAIRED},
{"pairing-code", required_argument, nullptr, OPT_PAIRING_CODE},
{"fixed-volume", no_argument, nullptr, OPT_FIXED_VOLUME},
{nullptr, 0, nullptr, 0},
};

Expand Down Expand Up @@ -713,6 +722,9 @@ bool parse_options(int argc, char* argv[], Options& out, std::FILE* err) {
case OPT_PAIRING_CODE:
apply(Opt::PairingCode, optarg);
break;
case OPT_FIXED_VOLUME:
apply(Opt::FixedVolume, "true");
break;
case OPT_MANUFACTURER:
apply(Opt::Manufacturer, optarg);
break;
Expand Down Expand Up @@ -852,7 +864,7 @@ bool parse_options(int argc, char* argv[], Options& out, std::FILE* err) {
Opt::NoMdns, Opt::MdnsName, Opt::NoControl, Opt::StateDir,
Opt::OutputDelay, Opt::HookStart, Opt::HookStop, Opt::ClientId,
Opt::Manufacturer, Opt::ProductName, Opt::AudioFormat, Opt::AllowUnpaired,
Opt::Input, Opt::PairingCode,
Opt::Input, Opt::PairingCode, Opt::FixedVolume,
};
for (Opt opt : DAEMON_ONLY) {
if (out.was_given(opt)) {
Expand Down Expand Up @@ -1041,6 +1053,12 @@ void print_usage(std::FILE* out, const char* prog) {
std::fprintf(out, " remembered one wins over this flag every run after.\n");
std::fprintf(out, " Use 'delay <ms>' to change a running player.\n");
std::fprintf(out, " --static-delay is still accepted as the old name\n");
std::fprintf(out, " --fixed-volume\n");
std::fprintf(out, " Play at full volume, unmuted, and let the server set\n");
std::fprintf(out, " neither: for an output whose level is set elsewhere,\n");
std::fprintf(out, " such as a line-out into an amplifier. A remembered\n");
std::fprintf(out, " volume is kept but ignored, and returns once the flag\n");
std::fprintf(out, " is dropped. 'vol' and 'mute' still address the group\n");
std::fprintf(out, " --allow-unpaired\n");
std::fprintf(out, " Let a server that has not paired with this player play\n");
std::fprintf(out, " on it. Off by default: every connection is encrypted,\n");
Expand Down
4 changes: 4 additions & 0 deletions src/cli.h
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ enum class Opt : unsigned {
AllowUnpaired, ///< --allow-unpaired
Input, ///< --input
PairingCode, ///< --pairing-code
FixedVolume, ///< --fixed-volume
};

/// Everything the flag surface configures.
Expand Down Expand Up @@ -117,6 +118,9 @@ struct Options {
/// --pairing-code <8 digits>: the static pairing code; empty offers the dynamic code instead.
std::string pairing_code;

/// --fixed-volume: play at full gain, unmuted, and withhold volume and mute from the server.
bool fixed_volume{false};

/// --no-mdns: do not advertise `_sendspin._tcp`.
bool no_mdns{false};

Expand Down
2 changes: 2 additions & 0 deletions src/control_common.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ const char* volume_source_note(VolumeSource source) {
return " (remembered from an earlier run; no server has set it)";
case VolumeSource::Server:
return "";
case VolumeSource::Fixed:
return " (fixed; the server cannot set it)";
case VolumeSource::SinkDefault:
break;
}
Expand Down
32 changes: 27 additions & 5 deletions src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -829,6 +829,9 @@ int main(int argc, char* argv[]) {
sendspin::PlayerRole& player = client.add_player(std::move(player_config));
// Also what makes the stored delay apply: non-adjustable delays are ignored.
player.set_output_delay_adjustable(true);
// Withheld together: a mute the server could still set would silence a fixed level.
player.set_volume_adjustable(!opts.fixed_volume);
player.set_mute_adjustable(!opts.fixed_volume);
sendspin::MetadataRole& metadata = client.add_metadata();
// Always added: advertised roles are a property of the build, not of --no-control.
sendspin::ControllerRole& controller = client.add_controller();
Expand Down Expand Up @@ -859,13 +862,32 @@ int main(int argc, char* argv[]) {
metadata.set_listener(&metadata_logger);
client.set_network_provider(&network_provider);

// Restore the remembered gain before connecting, so the role reports what the sink applies.
// Seed the sink before connecting, so the role reports what the sink applies.
const std::optional<uint8_t> remembered_volume = state_store.volume();
const std::optional<bool> remembered_muted = state_store.muted();
const uint8_t volume = remembered_volume.value_or(DEFAULT_SINK_VOLUME);
const bool muted = remembered_muted.value_or(false);
// Only when something was remembered, since restore_volume() marks the pair as restored.
if (remembered_volume.has_value() || remembered_muted.has_value()) {
const bool remembered = remembered_volume.has_value() || remembered_muted.has_value();
uint8_t volume = remembered_volume.value_or(DEFAULT_SINK_VOLUME);
bool muted = remembered_muted.value_or(false);
if (opts.fixed_volume) {
if (remembered) {
const std::string what =
remembered_volume.has_value()
? "volume " + std::to_string(volume) + (muted ? " (muted)" : "")
: std::string("mute state");
// Left in the state file untouched, for a later run without the flag.
log_line(LogLevel::INFO, LOG_TAG_PLAYER,
"Volume is fixed at full output: the %s remembered in %s is ignored while "
"--fixed-volume is set, and returns once the flag is dropped",
what.c_str(), state_store.path().c_str());
} else {
log_line(LogLevel::INFO, LOG_TAG_PLAYER,
"Volume is fixed at full output; the server cannot set it");
}
player_listener.fix_volume();
volume = DEFAULT_SINK_VOLUME;
muted = false;
} else if (remembered) {
// Only when something was remembered, since restore_volume() marks the pair as restored.
player_listener.restore_volume(volume, muted);
log_line(LogLevel::INFO, LOG_TAG_PLAYER, "Restored volume %u%s from %s",
static_cast<unsigned>(volume), muted ? " (muted)" : "",
Expand Down
15 changes: 15 additions & 0 deletions src/player_listener.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -98,13 +98,20 @@ void PlayerListener::on_stream_end() {
}

void PlayerListener::on_volume_changed(uint8_t volume) {
// A command that slips through while fixed must not reach the sink or the state file.
if (this->volume_source_ == VolumeSource::Fixed) {
return;
}
this->sink_.set_volume(volume);
this->applied_volume_ = volume;
this->volume_source_ = VolumeSource::Server;
this->persist_volume();
}

void PlayerListener::on_mute_changed(bool muted) {
if (this->volume_source_ == VolumeSource::Fixed) {
return;
}
this->sink_.set_muted(muted);
this->applied_muted_ = muted;
this->volume_source_ = VolumeSource::Server;
Expand All @@ -123,6 +130,14 @@ void PlayerListener::restore_volume(uint8_t volume, bool muted) {
this->volume_source_ = VolumeSource::Restored;
}

void PlayerListener::fix_volume() {
this->sink_.set_volume(DEFAULT_SINK_VOLUME);
this->sink_.set_muted(false);
this->applied_volume_ = DEFAULT_SINK_VOLUME;
this->applied_muted_ = false;
this->volume_source_ = VolumeSource::Fixed;
}

void PlayerListener::persist_volume() const {
if (this->store_ == nullptr) {
return;
Expand Down
3 changes: 3 additions & 0 deletions src/player_listener.h
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ class PlayerListener final : public sendspin::PlayerRoleListener {
/// Seeds the sink from the state store without marking it server-set. Call before connecting.
void restore_volume(uint8_t volume, bool muted);

/// Pins the sink at full gain, unmuted, and ignores volume and mute from then on.
void fix_volume();

/// The format the sink was configured for; absent between streams and for a refused one.
const std::optional<StreamFormat>& stream_format() const {
return this->stream_format_;
Expand Down
10 changes: 10 additions & 0 deletions tests/cli_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,16 @@ TEST(ParseOptions, AllowUnpairedIsOffUnlessAsked) {
EXPECT_TRUE(on.options().allow_unpaired);
}

TEST(ParseOptions, FixedVolumeIsOffUnlessAsked) {
Parse off({});
Parse on({"--fixed-volume"});

ASSERT_TRUE(off.ok()) << off.diagnostics();
ASSERT_TRUE(on.ok()) << on.diagnostics();
EXPECT_FALSE(off.options().fixed_volume);
EXPECT_TRUE(on.options().fixed_volume);
}

TEST(ParseOptions, OutputDelayDefaultsToNoDelay) {
Parse parse({});

Expand Down
19 changes: 19 additions & 0 deletions tests/config_file_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,24 @@ TEST(ConfigPrecedence, AllowUnpairedIsSettableAndOffByDefault) {
EXPECT_FALSE(defaulted.options().allow_unpaired);
}

TEST(ConfigPrecedence, FixedVolumeIsSettableAndOffByDefault) {
ScratchDir scratch;
ASSERT_TRUE(scratch.created());
const std::string on = scratch.write("on", "fixed-volume = true\n");
const std::string off = scratch.write("off", "fixed-volume = false\n");
const std::string empty = scratch.write("empty", "name = kitchen\n");

Parse enabled({}, on);
Parse disabled({}, off);
Parse defaulted({}, empty);
ASSERT_TRUE(enabled.ok()) << enabled.diagnostics();
ASSERT_TRUE(disabled.ok()) << disabled.diagnostics();
ASSERT_TRUE(defaulted.ok()) << defaulted.diagnostics();
EXPECT_TRUE(enabled.options().fixed_volume);
EXPECT_FALSE(disabled.options().fixed_volume);
EXPECT_FALSE(defaulted.options().fixed_volume);
}

TEST(ConfigPrecedence, PairingCodeIsSettableAndLosesToTheCommandLine) {
ScratchDir scratch;
ASSERT_TRUE(scratch.created());
Expand Down Expand Up @@ -424,6 +442,7 @@ TEST(ConfigRefusals, ABadValueGetsTheFlagsOwnMessagePrefixedWithTheLine) {
{"name =", "-n needs a non-empty value"},
{"no-mdns = perhaps", "invalid --no-mdns 'perhaps'"},
{"allow-unpaired = perhaps", "invalid --allow-unpaired 'perhaps'"},
{"fixed-volume = maybe", "invalid --fixed-volume 'maybe'"},
{"pairing-code = 1234567", "invalid --pairing-code -- expected exactly 8 digits"},
{"pairing-code = 12345678x", "invalid --pairing-code -- expected exactly 8 digits"},
{"pairing-code =", "invalid --pairing-code -- expected exactly 8 digits"},
Expand Down
9 changes: 9 additions & 0 deletions tests/control_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,15 @@ TEST(FormatStatus, ARestoredVolumeIsNeitherADefaultNorAServersChoice) {
EXPECT_NE(line.find("no server has set it"), std::string::npos) << line;
}

TEST(FormatStatus, AFixedVolumeSaysTheServerCannotSetIt) {
StatusSnapshot snapshot = playing_snapshot();
snapshot.player_volume = DEFAULT_SINK_VOLUME;
snapshot.player_volume_source = VolumeSource::Fixed;

const std::string line = field(format_status(snapshot), "player volume");
EXPECT_EQ(line, "100 (fixed; the server cannot set it)");
}

TEST(FormatStatus, TheOutputDelayIsReportedSoItsCommandIsVisible) {
StatusSnapshot snapshot = playing_snapshot();
snapshot.output_delay_ms = 375;
Expand Down
Loading
Loading