diff --git a/README.md b/README.md index e63e534..207be7b 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ sendspin-cli -n den --input default | `--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 (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 ` | 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/Configuration.md b/docs/wiki/Configuration.md index e8bc494..f76cae2 100644 --- a/docs/wiki/Configuration.md +++ b/docs/wiki/Configuration.md @@ -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` | diff --git a/docs/wiki/Controlling-the-Player.md b/docs/wiki/Controlling-the-Player.md index 6b1c94d..1bd1cc3 100644 --- a/docs/wiki/Controlling-the-Player.md +++ b/docs/wiki/Controlling-the-Player.md @@ -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 diff --git a/packaging/sendspin-cli.conf.example b/packaging/sendspin-cli.conf.example index 00c02ff..6a64d72 100644 --- a/packaging/sendspin-cli.conf.example +++ b/packaging/sendspin-cli.conf.example @@ -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 diff --git a/src/audio_sink.h b/src/audio_sink.h index 96f569e..833dcd2 100644 --- a/src/audio_sink.h +++ b/src/audio_sink.h @@ -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. diff --git a/src/cli.cpp b/src/cli.cpp index 8f43e33..37a83c7 100644 --- a/src/cli.cpp +++ b/src/cli.cpp @@ -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. @@ -109,6 +110,7 @@ const std::vector& 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; } @@ -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) { @@ -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}, }; @@ -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; @@ -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)) { @@ -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 ' 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"); diff --git a/src/cli.h b/src/cli.h index c47893c..f6131c3 100644 --- a/src/cli.h +++ b/src/cli.h @@ -83,6 +83,7 @@ enum class Opt : unsigned { AllowUnpaired, ///< --allow-unpaired Input, ///< --input PairingCode, ///< --pairing-code + FixedVolume, ///< --fixed-volume }; /// Everything the flag surface configures. @@ -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}; diff --git a/src/control_common.cpp b/src/control_common.cpp index 95e9e61..bbc0693 100644 --- a/src/control_common.cpp +++ b/src/control_common.cpp @@ -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; } diff --git a/src/main.cpp b/src/main.cpp index 6cd2bf5..fa2b88b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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(); @@ -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 remembered_volume = state_store.volume(); const std::optional 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(volume), muted ? " (muted)" : "", diff --git a/src/player_listener.cpp b/src/player_listener.cpp index 34cb373..c8d3db8 100644 --- a/src/player_listener.cpp +++ b/src/player_listener.cpp @@ -98,6 +98,10 @@ 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; @@ -105,6 +109,9 @@ void PlayerListener::on_volume_changed(uint8_t 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; @@ -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; diff --git a/src/player_listener.h b/src/player_listener.h index 1f2e316..c3e5dc4 100644 --- a/src/player_listener.h +++ b/src/player_listener.h @@ -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& stream_format() const { return this->stream_format_; diff --git a/tests/cli_test.cpp b/tests/cli_test.cpp index 9786df1..5abeeea 100644 --- a/tests/cli_test.cpp +++ b/tests/cli_test.cpp @@ -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({}); diff --git a/tests/config_file_test.cpp b/tests/config_file_test.cpp index 5170f84..bdaf6ab 100644 --- a/tests/config_file_test.cpp +++ b/tests/config_file_test.cpp @@ -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()); @@ -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"}, diff --git a/tests/control_test.cpp b/tests/control_test.cpp index 0759468..8cc246a 100644 --- a/tests/control_test.cpp +++ b/tests/control_test.cpp @@ -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; diff --git a/tests/player_listener_test.cpp b/tests/player_listener_test.cpp index fc9ea3d..ff112ee 100644 --- a/tests/player_listener_test.cpp +++ b/tests/player_listener_test.cpp @@ -12,16 +12,23 @@ // See the License for the specific language governing permissions and // limitations under the License. -/// PlayerListener's stream events, against a client that is never started. +/// PlayerListener's stream events and volume handling, against a client that is never started. #include "player_listener.h" #include "null_sink.h" +#include "state_store.h" #include #include +#include +#include +#include +#include +#include +#include #include namespace sendspin_cli { @@ -29,11 +36,11 @@ namespace { /// A listener on an unstarted client's player role, recording every stream event. struct Harness { - Harness() + explicit Harness(StateStore* store = nullptr) : client(sendspin::SendspinClientConfig{}), player(client.add_player(player_config())), sink(NullSinkOutput::Discard), - listener(player, sink) { + listener(player, sink, store) { this->listener.on_stream_event = [this](bool started) { this->events.push_back(started); }; } @@ -50,6 +57,79 @@ struct Harness { std::vector events; }; +/// A state file under the build tree holding a remembered volume, removed afterwards. +class RememberedVolume { +public: + RememberedVolume() { + this->dir_ = "player-listener-test-" + std::to_string(getpid()); + this->created_ = ::mkdir(this->dir_.c_str(), 0700) == 0; + StateStore store(this->path()); + this->created_ = this->created_ && store.set_volume_and_muted(42, true); + } + ~RememberedVolume() { + std::remove(this->path().c_str()); + ::rmdir(this->dir_.c_str()); + } + RememberedVolume(const RememberedVolume&) = delete; + RememberedVolume& operator=(const RememberedVolume&) = delete; + + bool created() const { + return this->created_; + } + std::string path() const { + return this->dir_ + "/state"; + } + std::string contents() const { + std::ifstream in(this->path(), std::ios::binary); + return {std::istreambuf_iterator(in), std::istreambuf_iterator()}; + } + +private: + std::string dir_; + bool created_{false}; +}; + +TEST(PlayerListener, AServerVolumeIsAppliedAndPersisted) { + RememberedVolume remembered; + ASSERT_TRUE(remembered.created()); + StateStore store(remembered.path()); + size_t malformed = 0; + ASSERT_EQ(store.load(malformed), StateLoadResult::Loaded); + Harness harness(&store); + harness.listener.restore_volume(42, true); + + harness.listener.on_volume_changed(60); + harness.listener.on_mute_changed(false); + + EXPECT_EQ(harness.listener.applied_volume(), 60U); + EXPECT_FALSE(harness.listener.applied_muted()); + EXPECT_EQ(harness.listener.volume_source(), VolumeSource::Server); + EXPECT_EQ(store.volume(), 60U); + EXPECT_EQ(store.muted(), false); + EXPECT_NE(remembered.contents().find("volume = 60"), std::string::npos); +} + +TEST(PlayerListener, AFixedVolumeIgnoresServerCommandsAndLeavesTheStateFileAlone) { + RememberedVolume remembered; + ASSERT_TRUE(remembered.created()); + StateStore store(remembered.path()); + size_t malformed = 0; + ASSERT_EQ(store.load(malformed), StateLoadResult::Loaded); + const std::string before = remembered.contents(); + Harness harness(&store); + + harness.listener.fix_volume(); + harness.listener.on_volume_changed(60); + harness.listener.on_mute_changed(true); + + EXPECT_EQ(harness.listener.applied_volume(), DEFAULT_SINK_VOLUME); + EXPECT_FALSE(harness.listener.applied_muted()); + EXPECT_EQ(harness.listener.volume_source(), VolumeSource::Fixed); + EXPECT_EQ(store.volume(), 42U); + EXPECT_EQ(store.muted(), true); + EXPECT_EQ(remembered.contents(), before); +} + TEST(PlayerListener, AnEndWithNoStartFiresNoStopEvent) { Harness harness;