From e1841aad79e43ea3a413a203d6cb3ae76ce0df39 Mon Sep 17 00:00:00 2001 From: Chris Uthe Date: Thu, 15 Oct 2026 18:12:54 -0500 Subject: [PATCH] Add --fixed-volume for an output whose level is set elsewhere With the flag the player plays at full gain, unmuted, and withholds volume and mute from the server: neither appears in client/state or supported_commands, and a server command for either is ignored. A volume remembered in the state file is kept but not restored, with one INFO line saying so, and returns once the flag is dropped. status prints the player volume as fixed. vol and mute on the control socket are group commands and stay as they are. --- README.md | 1 + docs/wiki/Configuration.md | 1 + docs/wiki/Controlling-the-Player.md | 5 +- packaging/sendspin-cli.conf.example | 4 ++ src/audio_sink.h | 1 + src/cli.cpp | 20 ++++++- src/cli.h | 4 ++ src/control_common.cpp | 2 + src/main.cpp | 32 +++++++++-- src/player_listener.cpp | 15 +++++ src/player_listener.h | 3 + tests/cli_test.cpp | 10 ++++ tests/config_file_test.cpp | 19 +++++++ tests/control_test.cpp | 9 +++ tests/player_listener_test.cpp | 86 ++++++++++++++++++++++++++++- 15 files changed, 202 insertions(+), 10 deletions(-) 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;