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
7 changes: 6 additions & 1 deletion docs/integration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -1094,6 +1094,11 @@ player.update_output_delay(50); // User-adjustable delay in ms
// Enable/disable output delay adjustment by the server. When disabled, the stored delay
// is not applied to sync timing and is reported as 0 in client state.
player.set_output_delay_adjustable(true);

// Volume and mute are server-adjustable by default. Disable either for a fixed-level output
// (e.g. line out to an amplifier): client state omits its value and the server's command is ignored.
player.set_volume_adjustable(false);
player.set_mute_adjustable(false);
```

## External Sources
Expand Down Expand Up @@ -1568,7 +1573,7 @@ format arrives as the `format` argument of `on_display_pairing_code`.
| `MUTE` | Mute state change from the server |
| `SET_OUTPUT_DELAY` | Output delay adjustment from the server |

These represent commands the server can send to the player. The player advertises which commands it supports. Enable `SET_OUTPUT_DELAY` with `player.set_output_delay_adjustable(true)`.
These represent commands the server can send to the player. The player advertises which commands it supports. `VOLUME` and `MUTE` are advertised unless disabled with `player.set_volume_adjustable(false)` / `player.set_mute_adjustable(false)`; enable `SET_OUTPUT_DELAY` with `player.set_output_delay_adjustable(true)`.

### SendspinGoodbyeReason

Expand Down
1 change: 1 addition & 0 deletions examples/basic_client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ macOS has mDNS support built in; no extra dependencies needed.
./build/examples/basic_client/basic_client -p 8930 # listen on a custom port
./build/examples/basic_client/basic_client -s 12345678 # offer a static pairing code
./build/examples/basic_client/basic_client -a # allow unpaired access
./build/examples/basic_client/basic_client -F -M # server cannot set volume or mute
```

The client listens on port 8928 by default. When mDNS is enabled it advertises `_sendspin._tcp` with the configured port so Sendspin servers on the local network discover and connect automatically; otherwise tell the server to connect with `ws://<this-host>:8928/sendspin`, replacing `8928` if you passed `-p`.
Expand Down
19 changes: 18 additions & 1 deletion examples/basic_client/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
/// -p PORT Listen on PORT (default: 8928)
/// -s CODE Offer the static pairing code CODE (8 digits) instead of the dynamic one
/// -a Enable unpaired access, so a server can stream without pairing first
/// -F The server cannot set the volume, which starts at full scale
/// -M The server cannot set the mute state
/// -l LEVEL Set log level: none, error, warn, info (default), debug, verbose
/// -v Verbose logging (same as -l verbose)
/// -q Quiet logging (same as -l error)
Expand Down Expand Up @@ -169,6 +171,8 @@ static void print_usage(const char* prog) {
fprintf(stderr, " SIGUSR2 cancels it\n");
fprintf(stderr, " -a Enable unpaired access, so a server can stream without\n");
fprintf(stderr, " pairing first\n");
fprintf(stderr, " -F The server cannot set the volume, which starts at full scale\n");
fprintf(stderr, " -M The server cannot set the mute state\n");
fprintf(stderr, " -l LEVEL Log level: none, error, warn, info (default), debug, verbose\n");
fprintf(stderr, " -v Verbose logging (same as -l verbose)\n");
fprintf(stderr, " -q Quiet logging (same as -l error)\n");
Expand Down Expand Up @@ -207,9 +211,11 @@ int main(int argc, char* argv[]) {
std::string connect_url;
std::string static_pairing_code;
bool unpaired_access = false;
bool server_volume = true;
bool server_mute = true;
uint16_t server_port = DEFAULT_SENDSPIN_PORT;
int opt;
while ((opt = getopt(argc, argv, "u:p:s:al:vqh")) != -1) {
while ((opt = getopt(argc, argv, "u:p:s:aFMl:vqh")) != -1) {
switch (opt) {
case 'u':
connect_url = optarg;
Expand All @@ -226,6 +232,12 @@ int main(int argc, char* argv[]) {
case 'a':
unpaired_access = true;
break;
case 'F':
server_volume = false;
break;
case 'M':
server_mute = false;
break;
case 'p':
if (!parse_port(optarg, server_port)) {
fprintf(stderr, "Invalid port: %s\n", optarg);
Expand Down Expand Up @@ -311,6 +323,11 @@ int main(int argc, char* argv[]) {
};
auto& player = client.add_player(std::move(player_config));
player.set_output_delay_adjustable(true);
player.set_volume_adjustable(server_volume);
player.set_mute_adjustable(server_mute);
if (!server_volume) {
player.update_volume(100);
}
#endif
#ifdef SENDSPIN_ENABLE_CONTROLLER
// Added for its side effect: the client offers the role and accepts server/state for it.
Expand Down
1 change: 1 addition & 0 deletions examples/tui_client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ brew install portaudio
./build/examples/tui_client/tui_client -u ws://192.168.1.10:8928/sendspin # connect to a specific server
./build/examples/tui_client/tui_client -p 8930 # listen on a custom port
./build/examples/tui_client/tui_client -a # allow unpaired access
./build/examples/tui_client/tui_client -F -M # server cannot set volume or mute
./build/examples/tui_client/tui_client -V # disable visualizer
```

Expand Down
19 changes: 18 additions & 1 deletion examples/tui_client/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
/// -p PORT Listen on PORT (default: 8928)
/// -f FORMAT Audio format as codec:rate:bits:channels (repeatable)
/// -a Enable unpaired access, so a server can stream without pairing first
/// -F The server cannot set the volume, which starts at full scale
/// -M The server cannot set the mute state
/// -V Disable visualizer
/// -h Show usage

Expand Down Expand Up @@ -442,6 +444,8 @@ static void print_usage(const char* prog) {
fprintf(stderr, " At least one flac or pcm format is required\n");
fprintf(stderr, " -a Enable unpaired access, so a server can stream without\n");
fprintf(stderr, " pairing first\n");
fprintf(stderr, " -F The server cannot set the volume, which starts at full scale\n");
fprintf(stderr, " -M The server cannot set the mute state\n");
fprintf(stderr, " -V Disable visualizer\n");
fprintf(stderr, " -h Show this help\n");
}
Expand All @@ -450,11 +454,13 @@ int main(int argc, char* argv[]) {
// Parse command line options
bool enable_visualizer = true;
bool unpaired_access = false;
bool server_volume = true;
bool server_mute = true;
std::string connect_url;
uint16_t server_port = DEFAULT_SENDSPIN_PORT;
std::vector<sendspin::AudioSupportedFormatObject> audio_formats;
int opt;
while ((opt = getopt(argc, argv, "u:p:f:aVh")) != -1) {
while ((opt = getopt(argc, argv, "u:p:f:aFMVh")) != -1) {
switch (opt) {
case 'u':
connect_url = optarg;
Expand All @@ -477,6 +483,12 @@ int main(int argc, char* argv[]) {
case 'a':
unpaired_access = true;
break;
case 'F':
server_volume = false;
break;
case 'M':
server_mute = false;
break;
case 'V':
enable_visualizer = false;
break;
Expand Down Expand Up @@ -593,6 +605,11 @@ int main(int argc, char* argv[]) {
player_config.audio_formats = std::move(audio_formats);
auto& player = client.add_player(std::move(player_config));
player.set_output_delay_adjustable(true);
player.set_volume_adjustable(server_volume);
player.set_mute_adjustable(server_mute);
if (!server_volume) {
player.update_volume(100);
}
#endif
#ifdef SENDSPIN_ENABLE_CONTROLLER
// Added for its side effect: the client offers the role and accepts server/state for it.
Expand Down
14 changes: 14 additions & 0 deletions include/sendspin/player_role.h
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,20 @@ class PlayerRole {
/// @param adjustable true if the delay can be adjusted at runtime
void set_output_delay_adjustable(bool adjustable);

/// @brief Enables or disables the server's volume command. Main loop only.
///
/// Enabled by default. While disabled, volume is omitted from client state and a server
/// volume command is ignored; the stored volume is kept.
/// @param adjustable true if the server may set the volume
void set_volume_adjustable(bool adjustable);

/// @brief Enables or disables the server's mute command. Main loop only.
///
/// Enabled by default. While disabled, the mute state is omitted from client state and a
/// server mute command is ignored; the stored state is kept.
/// @param adjustable true if the server may set the mute state
void set_mute_adjustable(bool adjustable);

// ========================================
// Queries
// ========================================
Expand Down
37 changes: 28 additions & 9 deletions src/player_role.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,16 @@ void PlayerRole::set_output_delay_adjustable(bool adjustable) {
this->impl_->client->publish_state();
}

void PlayerRole::set_volume_adjustable(bool adjustable) {
this->impl_->volume_adjustable = adjustable;
this->impl_->client->publish_state();
Comment thread
kahrendt marked this conversation as resolved.
}

void PlayerRole::set_mute_adjustable(bool adjustable) {
this->impl_->mute_adjustable = adjustable;
this->impl_->client->publish_state();
}

const ServerPlayerStreamObject& PlayerRole::get_current_stream_params() const {
return this->impl_->current_stream_params;
}
Expand Down Expand Up @@ -293,8 +303,6 @@ size_t PlayerRole::Impl::advertised_buffer_capacity() const {

void PlayerRole::Impl::build_state_fields(ClientStateMessage& msg) const {
ClientPlayerStateObject player_state{};
player_state.volume = this->volume;
player_state.muted = this->muted;
bool adjustable = this->output_delay_adjustable.load(std::memory_order_relaxed);
player_state.output_delay_ms =
adjustable ? this->output_delay_ms.load(std::memory_order_relaxed) : 0;
Expand All @@ -305,8 +313,16 @@ void PlayerRole::Impl::build_state_fields(ClientStateMessage& msg) const {
std::max(this->config.required_lead_time_ms.value_or(0),
PlayerRoleConfig::pipeline_lead_time_ms(this->config.extra_startup_silence_ms));
player_state.min_buffer_ms = this->config.min_buffer_ms;
// roles/player/v1.md "client/state player object": the commands the server may send.
player_state.supported_commands = {SendspinPlayerCommand::VOLUME, SendspinPlayerCommand::MUTE};
// roles/player/v1.md "client/state player object": the commands the server may send. volume
// and muted are required only alongside their command, so a withheld command omits its value.
if (this->volume_adjustable) {
player_state.volume = this->volume;
player_state.supported_commands.push_back(SendspinPlayerCommand::VOLUME);
}
if (this->mute_adjustable) {
player_state.muted = this->muted;
player_state.supported_commands.push_back(SendspinPlayerCommand::MUTE);
}
if (adjustable) {
player_state.supported_commands.push_back(SendspinPlayerCommand::SET_OUTPUT_DELAY);
}
Expand Down Expand Up @@ -504,26 +520,29 @@ void PlayerRole::Impl::drain_events() {
// Check each field independently since multiple command types may have been merged into one
// inbox slot between drain ticks. Each callback may re-enter teardown (a listener calling
// stop()), whose own drain already reset this role, so the rest is abandoned when the
// generation moved on.
// generation moved on. roles/player/v1.md "server/command player object": a command absent
// from the current supported_commands is ignored.
if (have_command && cmd_msg.player.has_value()) {
const ServerPlayerCommandObject& player_cmd = cmd_msg.player.value();

if (player_cmd.volume.has_value()) {
if (player_cmd.volume.has_value() && !this->volume_adjustable) {
SS_LOGD(TAG, "Ignoring volume: not in supported_commands");
} else if (player_cmd.volume.has_value()) {
this->update_volume(player_cmd.volume.value());
if (this->listener) {
this->listener->on_volume_changed(player_cmd.volume.value());
}
}

if (player_cmd.mute.has_value() && this->accepts(generation)) {
if (player_cmd.mute.has_value() && !this->mute_adjustable) {
SS_LOGD(TAG, "Ignoring mute: not in supported_commands");
} else if (player_cmd.mute.has_value() && this->accepts(generation)) {
this->update_muted(player_cmd.mute.value());
if (this->listener) {
this->listener->on_mute_changed(player_cmd.mute.value());
}
}

// roles/player/v1.md "server/command player object": a command absent from the current
// supported_commands is ignored.
const bool delay_advertised = this->output_delay_adjustable.load(std::memory_order_relaxed);
if (player_cmd.output_delay_ms.has_value() && !delay_advertised) {
SS_LOGD(TAG, "Ignoring set_output_delay: not in supported_commands");
Expand Down
2 changes: 2 additions & 0 deletions src/player_role_impl.h
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ struct PlayerRole::Impl : RoleTeardown {
// to go idle; the drain then runs again when sync_idle_slot is written. Main loop only.
bool awaiting_sync_idle{false};
bool high_performance_requested_for_playback{false};
bool mute_adjustable{true};
bool muted{false};
// True between the drained STREAM_START and STREAM_END callbacks (main-thread only); keeps
// on_stream_end() from firing without a matching on_stream_start()
Expand All @@ -224,6 +225,7 @@ struct PlayerRole::Impl : RoleTeardown {
/// and the sync task.
std::atomic<bool> output_delay_adjustable{false};
uint8_t volume{0};
bool volume_adjustable{true};
};

} // namespace sendspin
8 changes: 6 additions & 2 deletions src/protocol.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -949,8 +949,12 @@ std::string format_client_state_message(const ClientStateMessage* msg,

if (msg->player.has_value()) {
const ClientPlayerStateObject& player_state = msg->player.value();
root["payload"]["player"]["volume"] = player_state.volume;
root["payload"]["player"]["muted"] = player_state.muted;
if (player_state.volume.has_value()) {
root["payload"]["player"]["volume"] = player_state.volume.value();
}
if (player_state.muted.has_value()) {
root["payload"]["player"]["muted"] = player_state.muted.value();
}
root["payload"]["player"]["output_delay_ms"] = player_state.output_delay_ms;
root["payload"]["player"]["required_lead_time_ms"] = player_state.required_lead_time_ms;
root["payload"]["player"]["min_buffer_ms"] = player_state.min_buffer_ms;
Expand Down
4 changes: 2 additions & 2 deletions src/protocol_messages.h
Original file line number Diff line number Diff line change
Expand Up @@ -495,8 +495,8 @@ static constexpr uint8_t VOLUME_MAX = 100;

/// @brief Player state reported by the client to the server in client/state messages
struct ClientPlayerStateObject {
uint8_t volume{};
bool muted{};
std::optional<uint8_t> volume;
std::optional<bool> muted;
uint16_t output_delay_ms{};
uint16_t required_lead_time_ms{};
uint16_t min_buffer_ms{};
Expand Down
26 changes: 13 additions & 13 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -158,20 +158,20 @@ set_source_files_properties(test_client_lifecycle.cpp PROPERTIES COMPILE_OPTIONS
# role inbox's topic bits).
set_source_files_properties(test_role_deactivation.cpp PROPERTIES COMPILE_OPTIONS -fno-access-control)

# test_player_role.cpp drives PlayerRole::Impl directly - no connected server, no sync-task thread -
# test_player_role.cpp drives PlayerRole::Impl directly (no connected server, no sync-task thread),
# which is the only way to see the client/state player object and the only way to stage a slot
# payload stamped before a teardown. Reached: Impl's handle_stream_start(), handle_binary(),
# handle_stream_clear(), handle_stream_end(), handle_server_command(), cleanup(), attach_inbox(),
# drain_events(), volume, cleanup_generation (moved on directly to stage a take between a teardown's
# generation bump and its recall) and event_state's generation-stamped slots (one written with a
# stamp from before a teardown, the payload a drain on the far side of it would hold); SyncTask's
# inbound_ (and take_item()), bound to a test ring the way start() binds it but with no thread, so
# what a handler handed over (or dropped) stays on the list to be read, and
# discard_to_clear_marker(), the sync thread's stream/clear step; and SyncTask's
# handle_synchronize_audio(), handle_transfer_audio() and starved_playtime_floor(), the inner
# loop's hard-sync steps driven without the thread. The inbound tests also bind a
# VisualizerRole::Impl to the same ring, to show one holder's quota does not stop the other. The
# availability table reaches SendspinClient's protocol_task_, build_client_state(),
# payload stamped before a teardown. Reached: PlayerRole's impl_ (a role add_player() built), Impl's
# handle_stream_start(), handle_binary(), handle_stream_clear(), handle_stream_end(),
# handle_server_command(), cleanup(), attach_inbox(), drain_events(), volume, cleanup_generation
# (moved on directly to stage a take between a teardown's generation bump and its recall) and
# event_state's generation-stamped slots (one written with a stamp from before a teardown, the
# payload a drain on the far side of it would hold); SyncTask's inbound_ (and take_item()), bound to
# a test ring the way start() binds it but with no thread, so what a handler handed over (or
# dropped) stays on the list to be read, and discard_to_clear_marker(), the sync thread's
# stream/clear step; and SyncTask's handle_synchronize_audio(), handle_transfer_audio() and
# starved_playtime_floor(), the inner loop's hard-sync steps driven without the thread. The inbound
# tests also bind a VisualizerRole::Impl to the same ring, to show one holder's quota does not stop
# the other. The availability table reaches SendspinClient's protocol_task_, build_client_state(),
# adopt_client_state(), connection_manager_ (its install_admitted()) and process_binary_message():
# the client is never started, so the test publishes and adopts each snapshot and dispatches the
# chunk itself.
Expand Down
Loading
Loading