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 @@ -91,6 +91,7 @@ sendspin-cli -n den --input default
| `--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. |
| `--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 <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 @@ -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` |
Expand Down
18 changes: 18 additions & 0 deletions src/alsa_sink.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,24 @@ void AlsaAudioSink::clear() {
}
}

void AlsaAudioSink::release() {
const std::lock_guard<std::mutex> 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);
Expand Down
1 change: 1 addition & 0 deletions src/alsa_sink.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions src/audio_sink.h
Original file line number Diff line number Diff line change
Expand Up @@ -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() {}

Expand Down
20 changes: 19 additions & 1 deletion src/cli.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -111,6 +112,7 @@ const std::vector<SettableOption>& 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;
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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},
};

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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");
Expand Down
4 changes: 4 additions & 0 deletions src/cli.h
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ enum class Opt : unsigned {
Input, ///< --input
PairingCode, ///< --pairing-code
FixedVolume, ///< --fixed-volume
ReleaseDevice, ///< --release-device
};

/// Everything the flag surface configures.
Expand Down Expand Up @@ -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};

Expand Down
13 changes: 13 additions & 0 deletions src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 6 additions & 1 deletion src/player_listener.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
6 changes: 6 additions & 0 deletions src/player_listener.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<StreamFormat>& stream_format() const {
return this->stream_format_;
Expand All @@ -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};
Expand Down
17 changes: 17 additions & 0 deletions src/portaudio_sink.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,23 @@ void PortAudioSink::clear() {
this->ring_.drop();
}

void PortAudioSink::release() {
const std::lock_guard<std::mutex> 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);
Expand Down
1 change: 1 addition & 0 deletions src/portaudio_sink.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions src/sink_recovery.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions src/sink_recovery.h
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
10 changes: 10 additions & 0 deletions tests/cli_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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({});

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 @@ -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());
Expand Down Expand Up @@ -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"},
Expand Down
63 changes: 63 additions & 0 deletions tests/player_listener_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,50 @@ struct Harness {
std::vector<bool> events;
};

/// A sink that logs flushes and releases, beside the listener's stop events.
class RecordingSink final : public AudioSink {
public:
explicit RecordingSink(std::vector<std::string>& 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<std::string>& 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<std::string> 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:
Expand Down Expand Up @@ -149,5 +193,24 @@ TEST(PlayerListener, ARepeatedEndFiresOneStopEventPerStart) {
EXPECT_EQ(harness.events, (std::vector<bool>{true, false}));
}

TEST(PlayerListener, AStreamEndClearsTheSinkByDefault) {
RecordingHarness harness;

harness.listener.on_stream_start();
harness.listener.on_stream_end();

EXPECT_EQ(harness.log, (std::vector<std::string>{"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<std::string>{"start", "release", "stop"}));
}

} // namespace
} // namespace sendspin_cli
Loading
Loading