diff --git a/README.md b/README.md index 2a83db7..a7aed3c 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ sendspin-cli -n den --input default | `--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. | +| `--release-device` | Close the output device when a stream ends and reopen it when the next one starts, so other players can take turns on an exclusive device such as an ALSA `hw:` DAC. Off by default: between streams another app can take the device, and the next stream is then discarded. ALSA and PortAudio only. | | `--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 f76cae2..9743c6d 100644 --- a/docs/wiki/Configuration.md +++ b/docs/wiki/Configuration.md @@ -59,6 +59,7 @@ already does that. | `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` | +| `release-device` | `--release-device` | `true`/`false` — close the output device at each stream end and reopen it at the next stream start, for an exclusive device such as an ALSA `hw:` DAC shared with other players (Squeezelite, AirPlay, Spotify). The trade-off: another app can take the device between streams, and the next stream is then discarded; each stream also pays a fresh open. ALSA and PortAudio only; other backends ignore it | `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/src/alsa_sink.cpp b/src/alsa_sink.cpp index afd34d5..c48a565 100644 --- a/src/alsa_sink.cpp +++ b/src/alsa_sink.cpp @@ -675,6 +675,24 @@ void AlsaAudioSink::clear() { } } +void AlsaAudioSink::release() { + const std::lock_guard lock(this->device_mutex_); + + this->current_multiplier_ = this->target_multiplier_.load(std::memory_order_relaxed); + // Nothing to reopen at, so poll() leaves the device to the next configure(). + this->last_format_ = {}; + this->recovery_.cancel(); + // Latched so a late write() discards without the device-lost error. + this->failed_.store(true); + + if (this->pcm_ == nullptr) { + return; + } + snd_pcm_drop(this->pcm_); + this->close_device_(); + cli_log(LogLevel::INFO, "alsa: '%s' released", this->device_.c_str()); +} + void AlsaAudioSink::stop() { // Before the lock, so a blocked write() bails out. this->stopping_.store(true); diff --git a/src/alsa_sink.h b/src/alsa_sink.h index 99b3efb..5c5b18a 100644 --- a/src/alsa_sink.h +++ b/src/alsa_sink.h @@ -43,6 +43,7 @@ class AlsaAudioSink final : public AudioSink { bool configure(uint32_t sample_rate, uint8_t channels, uint8_t bits_per_sample) override; size_t write(const uint8_t* data, size_t length, uint32_t timeout_ms) override; void clear() override; + void release() override; void stop() override; void set_volume(uint8_t volume) override; void set_muted(bool muted) override; diff --git a/src/audio_sink.h b/src/audio_sink.h index 05acd64..3158f95 100644 --- a/src/audio_sink.h +++ b/src/audio_sink.h @@ -88,6 +88,11 @@ class AudioSink { /// Drops buffered audio without releasing the device. virtual void clear() {} + /// Drops buffered audio and closes the device until the next configure(). + virtual void release() { + this->clear(); + } + /// Releases the device. Called once, during shutdown. virtual void stop() {} diff --git a/src/cli.cpp b/src/cli.cpp index 37a83c7..ae9ed27 100644 --- a/src/cli.cpp +++ b/src/cli.cpp @@ -71,6 +71,7 @@ enum LongOnly { OPT_INPUT, OPT_PAIRING_CODE, OPT_FIXED_VOLUME, + OPT_RELEASE_DEVICE, }; /// An option a config file may set: its key (the long flag name) and how diagnostics name it. @@ -111,6 +112,7 @@ const std::vector& settable_options() { {Opt::AllowUnpaired, "allow-unpaired", "--allow-unpaired"}, {Opt::PairingCode, "pairing-code", "--pairing-code"}, {Opt::FixedVolume, "fixed-volume", "--fixed-volume"}, + {Opt::ReleaseDevice, "release-device", "--release-device"}, }; return table; } @@ -415,6 +417,12 @@ bool apply_option(const SettableOption& option, const std::string& value, Option return false; } break; + case Opt::ReleaseDevice: + if (!parse_bool(value, out.release_device)) { + error = "invalid --release-device '" + 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) { @@ -580,6 +588,7 @@ bool parse_options(int argc, char* argv[], Options& out, std::FILE* err) { {"allow-unpaired", no_argument, nullptr, OPT_ALLOW_UNPAIRED}, {"pairing-code", required_argument, nullptr, OPT_PAIRING_CODE}, {"fixed-volume", no_argument, nullptr, OPT_FIXED_VOLUME}, + {"release-device", no_argument, nullptr, OPT_RELEASE_DEVICE}, {nullptr, 0, nullptr, 0}, }; @@ -725,6 +734,9 @@ bool parse_options(int argc, char* argv[], Options& out, std::FILE* err) { case OPT_FIXED_VOLUME: apply(Opt::FixedVolume, "true"); break; + case OPT_RELEASE_DEVICE: + apply(Opt::ReleaseDevice, "true"); + break; case OPT_MANUFACTURER: apply(Opt::Manufacturer, optarg); break; @@ -864,7 +876,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::FixedVolume, + Opt::Input, Opt::PairingCode, Opt::FixedVolume, Opt::ReleaseDevice, }; for (Opt opt : DAEMON_ONLY) { if (out.was_given(opt)) { @@ -1059,6 +1071,12 @@ void print_usage(std::FILE* out, const char* prog) { 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, " --release-device\n"); + std::fprintf(out, " Close the output device when a stream ends and reopen\n"); + std::fprintf(out, " it at the next, so other players can share an exclusive\n"); + std::fprintf(out, " device such as an ALSA hw: DAC. Another app may then\n"); + std::fprintf(out, " hold it when a stream starts, and that stream is\n"); + std::fprintf(out, " discarded. ALSA and PortAudio only\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 f6131c3..9c02687 100644 --- a/src/cli.h +++ b/src/cli.h @@ -84,6 +84,7 @@ enum class Opt : unsigned { Input, ///< --input PairingCode, ///< --pairing-code FixedVolume, ///< --fixed-volume + ReleaseDevice, ///< --release-device }; /// Everything the flag surface configures. @@ -121,6 +122,9 @@ struct Options { /// --fixed-volume: play at full gain, unmuted, and withhold volume and mute from the server. bool fixed_volume{false}; + /// --release-device: close the output device at stream end instead of holding it. + bool release_device{false}; + /// --no-mdns: do not advertise `_sendspin._tcp`. bool no_mdns{false}; diff --git a/src/main.cpp b/src/main.cpp index fa2b88b..84231ef 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -852,6 +852,19 @@ int main(int argc, char* argv[]) { } PlayerListener player_listener(player, *sink, &state_store); + if (opts.release_device) { + DeviceSpec output; + std::string output_error; + resolve_device_spec(opts.device, output, output_error); + if (output.backend == SinkBackend::Alsa || output.backend == SinkBackend::PortAudio) { + player_listener.release_device_between_streams(); + } else { + log_line(LogLevel::INFO, LOG_TAG_AUDIO, + "--release-device does nothing for -o %s -- only ALSA and PortAudio hold a " + "device between streams", + opts.device.c_str()); + } + } MetadataLogger metadata_logger; PairingListener pairing_listener; HostNetworkProvider network_provider; diff --git a/src/player_listener.cpp b/src/player_listener.cpp index c8d3db8..a1b2682 100644 --- a/src/player_listener.cpp +++ b/src/player_listener.cpp @@ -91,7 +91,12 @@ void PlayerListener::on_stream_end() { } else { cli_log(LogLevel::INFO, "Stream ended"); } - this->sink_.clear(); + // Before the stop hook, so a hook that starts another player finds the device free. + if (this->release_device_) { + this->sink_.release(); + } else { + this->sink_.clear(); + } if (this->on_stream_event) { this->on_stream_event(false); } diff --git a/src/player_listener.h b/src/player_listener.h index c3e5dc4..41b034b 100644 --- a/src/player_listener.h +++ b/src/player_listener.h @@ -74,6 +74,11 @@ class PlayerListener final : public sendspin::PlayerRoleListener { /// Pins the sink at full gain, unmuted, and ignores volume and mute from then on. void fix_volume(); + /// Closes the sink's device at each stream end, so other players can open it between streams. + void release_device_between_streams() { + this->release_device_ = true; + } + /// 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_; @@ -89,6 +94,7 @@ class PlayerListener final : public sendspin::PlayerRoleListener { /// Main loop only, so not atomic. bool streaming_{false}; + bool release_device_{false}; /// What the sink was last told; starts at the sink's own default. uint8_t applied_volume_{DEFAULT_SINK_VOLUME}; diff --git a/src/portaudio_sink.cpp b/src/portaudio_sink.cpp index 80acd5d..220dd7f 100644 --- a/src/portaudio_sink.cpp +++ b/src/portaudio_sink.cpp @@ -399,6 +399,23 @@ void PortAudioSink::clear() { this->ring_.drop(); } +void PortAudioSink::release() { + const std::lock_guard lock(this->mutex_); + + // Nothing to reopen at, so neither write() nor poll() brings the stream back. + this->last_format_ = {}; + this->recovery_.cancel(); + this->gap_handoff_.forget(); + // Latched so a late write() discards without the not-playing error. + this->failed_.store(true); + + if (this->stream_ == nullptr) { + return; + } + this->close_stream_(); + cli_log(LogLevel::INFO, "portaudio: '%s' released", this->name().c_str()); +} + void PortAudioSink::stop() { // Before the mutex, so a blocked write() bails out. this->stopping_.store(true); diff --git a/src/portaudio_sink.h b/src/portaudio_sink.h index 5ea179a..5c98605 100644 --- a/src/portaudio_sink.h +++ b/src/portaudio_sink.h @@ -46,6 +46,7 @@ class PortAudioSink final : public AudioSink { bool configure(uint32_t sample_rate, uint8_t channels, uint8_t bits_per_sample) override; size_t write(const uint8_t* data, size_t length, uint32_t timeout_ms) override; void clear() override; + void release() override; void stop() override; void set_volume(uint8_t volume) override; void set_muted(bool muted) override; diff --git a/src/sink_recovery.cpp b/src/sink_recovery.cpp index 93234bb..5477a8b 100644 --- a/src/sink_recovery.cpp +++ b/src/sink_recovery.cpp @@ -114,6 +114,13 @@ void SinkRecovery::forget_discarded_frames() { this->discarded_frames_ = 0; } +void SinkRecovery::cancel() { + this->rescan_immediate_ = false; + this->rescan_owed_.store(false, std::memory_order_relaxed); + this->rescan_at_ms_ = NOT_STAMPED; + this->discarded_frames_ = 0; +} + void SinkRecovery::reset() { this->refill_(); this->discarded_frames_ = 0; diff --git a/src/sink_recovery.h b/src/sink_recovery.h index 3129e4d..20a1de6 100644 --- a/src/sink_recovery.h +++ b/src/sink_recovery.h @@ -75,6 +75,10 @@ class SinkRecovery { /// Drops the discarded-frame count, leaving the recovery budget alone. void forget_discarded_frames(); + /// Drops any owed rescan and the discarded-frame count, for a device closed on purpose. + /// Leaves the budget for the next configure()'s reset(). Not while a rescan is in flight. + void cancel(); + /// Refills the budget; call only when configure() really opened a stream. void reset(); diff --git a/tests/cli_test.cpp b/tests/cli_test.cpp index 5abeeea..a265a25 100644 --- a/tests/cli_test.cpp +++ b/tests/cli_test.cpp @@ -446,6 +446,16 @@ TEST(ParseOptions, FixedVolumeIsOffUnlessAsked) { EXPECT_TRUE(on.options().fixed_volume); } +TEST(ParseOptions, ReleaseDeviceIsOffUnlessAsked) { + Parse off({}); + Parse on({"--release-device"}); + + ASSERT_TRUE(off.ok()) << off.diagnostics(); + ASSERT_TRUE(on.ok()) << on.diagnostics(); + EXPECT_FALSE(off.options().release_device); + EXPECT_TRUE(on.options().release_device); +} + TEST(ParseOptions, OutputDelayDefaultsToNoDelay) { Parse parse({}); diff --git a/tests/config_file_test.cpp b/tests/config_file_test.cpp index bdaf6ab..db26d69 100644 --- a/tests/config_file_test.cpp +++ b/tests/config_file_test.cpp @@ -293,6 +293,24 @@ TEST(ConfigPrecedence, FixedVolumeIsSettableAndOffByDefault) { EXPECT_FALSE(defaulted.options().fixed_volume); } +TEST(ConfigPrecedence, ReleaseDeviceIsSettableAndOffByDefault) { + ScratchDir scratch; + ASSERT_TRUE(scratch.created()); + const std::string on = scratch.write("on", "release-device = true\n"); + const std::string off = scratch.write("off", "release-device = 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().release_device); + EXPECT_FALSE(disabled.options().release_device); + EXPECT_FALSE(defaulted.options().release_device); +} + TEST(ConfigPrecedence, PairingCodeIsSettableAndLosesToTheCommandLine) { ScratchDir scratch; ASSERT_TRUE(scratch.created()); @@ -443,6 +461,7 @@ TEST(ConfigRefusals, ABadValueGetsTheFlagsOwnMessagePrefixedWithTheLine) { {"no-mdns = perhaps", "invalid --no-mdns 'perhaps'"}, {"allow-unpaired = perhaps", "invalid --allow-unpaired 'perhaps'"}, {"fixed-volume = maybe", "invalid --fixed-volume 'maybe'"}, + {"release-device = maybe", "invalid --release-device '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/player_listener_test.cpp b/tests/player_listener_test.cpp index ff112ee..757d363 100644 --- a/tests/player_listener_test.cpp +++ b/tests/player_listener_test.cpp @@ -57,6 +57,50 @@ struct Harness { std::vector events; }; +/// A sink that logs flushes and releases, beside the listener's stop events. +class RecordingSink final : public AudioSink { +public: + explicit RecordingSink(std::vector& log) : log_(log) {} + + std::string name() const override { + return "recording"; + } + bool configure(uint32_t /*sample_rate*/, uint8_t /*channels*/, uint8_t /*bits*/) override { + return true; + } + size_t write(const uint8_t* /*data*/, size_t length, uint32_t /*timeout_ms*/) override { + return length; + } + void clear() override { + this->log_.emplace_back("clear"); + } + void release() override { + this->log_.emplace_back("release"); + } + +private: + std::vector& log_; +}; + +/// A listener over a RecordingSink, logging stream events into the sink's log. +struct RecordingHarness { + RecordingHarness() + : client(sendspin::SendspinClientConfig{}), + player(client.add_player(Harness::player_config())), + sink(log), + listener(player, sink) { + this->listener.on_stream_event = [this](bool started) { + this->log.emplace_back(started ? "start" : "stop"); + }; + } + + std::vector log; + sendspin::SendspinClient client; + sendspin::PlayerRole& player; + RecordingSink sink; + PlayerListener listener; +}; + /// A state file under the build tree holding a remembered volume, removed afterwards. class RememberedVolume { public: @@ -149,5 +193,24 @@ TEST(PlayerListener, ARepeatedEndFiresOneStopEventPerStart) { EXPECT_EQ(harness.events, (std::vector{true, false})); } +TEST(PlayerListener, AStreamEndClearsTheSinkByDefault) { + RecordingHarness harness; + + harness.listener.on_stream_start(); + harness.listener.on_stream_end(); + + EXPECT_EQ(harness.log, (std::vector{"start", "clear", "stop"})); +} + +TEST(PlayerListener, AStreamEndReleasesTheDeviceBeforeTheStopEventWhenAsked) { + RecordingHarness harness; + harness.listener.release_device_between_streams(); + + harness.listener.on_stream_start(); + harness.listener.on_stream_end(); + + EXPECT_EQ(harness.log, (std::vector{"start", "release", "stop"})); +} + } // namespace } // namespace sendspin_cli diff --git a/tests/sink_recovery_test.cpp b/tests/sink_recovery_test.cpp index 561414a..e8c107c 100644 --- a/tests/sink_recovery_test.cpp +++ b/tests/sink_recovery_test.cpp @@ -76,6 +76,19 @@ TEST(SinkRecovery, AFailedReopenEscalatesToTheRescan) { EXPECT_TRUE(recovery.pending()); } +TEST(SinkRecovery, CancellingDropsAnOwedRescanAndTheGap) { + SinkRecovery recovery; + escalate(recovery); + recovery.discard_frames(480U); + ASSERT_TRUE(recovery.pending()); + + recovery.cancel(); + + EXPECT_FALSE(recovery.pending()); + EXPECT_EQ(rescan_fires_at(recovery, T0, T0 + SINK_RESCAN_MAX_DELAY_MS), -1); + EXPECT_EQ(recovery.take_discarded_frames(), 0U); +} + TEST(SinkRecovery, ReturnsTheDiscardedGapOnceWhenADeviceComesBack) { SinkRecovery recovery; escalate(recovery);