Skip to content
Open
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
16 changes: 0 additions & 16 deletions src/artwork_role.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -29,28 +29,12 @@ static const char* const TAG = "sendspin.artwork";
// Constants
// ============================================================================

/// @brief Size of the big-endian 64-bit timestamp at the start of artwork binary messages
static constexpr size_t BINARY_TIMESTAMP_SIZE = 8;

/// @brief Timeout for blocking queue receive in decode thread (allows periodic command checks)
static constexpr uint32_t DRAIN_RECEIVE_TIMEOUT_MS = 100U;

// Event flag bits for decode thread signaling
static constexpr uint32_t COMMAND_STOP = (1 << 0);

// ============================================================================
// Big-endian helpers
// ============================================================================

/// @brief Swaps bytes of a big-endian 64-bit value to host byte order
static int64_t be64_to_host(const uint8_t* bytes) {
uint64_t val = 0;
for (int i = 0; i < 8; ++i) {
val = (val << 8) | bytes[i];
}
return static_cast<int64_t>(val);
}

namespace sendspin {

// ============================================================================
Expand Down
11 changes: 0 additions & 11 deletions src/player_role.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -22,22 +22,11 @@

static const char* const TAG = "sendspin.player";

/// @brief Size of the big-endian 64-bit timestamp at the start of player binary messages.
static constexpr size_t BINARY_TIMESTAMP_SIZE = 8;
static constexpr uint16_t MAX_STATIC_DELAY_MS = 5000U;
static constexpr uint32_t HEADER_SEND_TIMEOUT_MS = 100U;
// Denominator for the advertised buffer capacity fraction: advertises (N-1)/N of capacity
static constexpr size_t AUDIO_BUFFER_ADVERTISE_DENOMINATOR = 5;

/// @brief Swaps bytes of a big-endian 64-bit value to host byte order.
static int64_t be64_to_host(const uint8_t* bytes) {
uint64_t val = 0;
for (int i = 0; i < 8; ++i) {
val = (val << 8) | bytes[i];
}
return static_cast<int64_t>(val);
}

namespace sendspin {

// ============================================================================
Expand Down
63 changes: 63 additions & 0 deletions src/protocol.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,24 @@ bool process_server_command_message(JsonObject root, ServerCommandMessage* cmd_m
return true;
}

// A standalone section parser (server/state style) rather than a ServerCommandMessage field:
// the source command types are internal-only
bool process_server_command_source(JsonObject root, SourceCommand* source_cmd) {
if (source_cmd == nullptr || !root["payload"]["source"].is<JsonObject>()) {
return false;
}
// "command" is required with no default; a bad value rejects the whole source object
// (Sendspin spec, Source messages — Server command object)
auto command = read_enum_field(root["payload"]["source"]["command"], "command",
source_command_from_string);
if (!command) {
SS_LOGW(TAG, "Rejecting server/command source object: missing or invalid 'command'");
return false;
}
*source_cmd = command.value();
return true;
}

// server/state is parsed one section at a time rather than into a single aggregate struct. The
// caller runs on the network task (the ESP httpd task has a 4 KB stack), and an aggregate would
// keep every section's fields alive in the caller's frame for the whole parse while the section
Expand Down Expand Up @@ -883,6 +901,15 @@ std::string format_client_hello_message(const ClientHelloMessage* msg) {
}
}

// Required whenever source@v1 is advertised; features emitted only when set (Sendspin
// spec, Source messages — Hello support object)
if (msg->source_v1_support.has_value()) {
JsonObject source_json = root["payload"]["source@v1_support"].to<JsonObject>();
if (msg->source_v1_support.value().line_sense) {
source_json["features"]["line_sense"] = true;
}
}

std::string output;
serializeJson(doc, output);
return output;
Expand All @@ -909,11 +936,47 @@ std::string format_client_state_message(const ClientStateMessage* msg) {
}
}

// The source object may legitimately be empty; signal only when set (Sendspin spec,
// Source messages — Client state object)
if (msg->source.has_value()) {
JsonObject source_json = root["payload"]["source"].to<JsonObject>();
if (msg->source.value().signal.has_value()) {
source_json["signal"] = to_cstr(msg->source.value().signal.value());
}
}

std::string output;
serializeJson(doc, output);
return output;
}

std::string format_client_stream_start_message(const ClientStreamStartMessage* msg) {
JsonDocument doc = make_json_document();
JsonObject root = doc.to<JsonObject>();

// Hyphenated type string per the spec; codec_header only when present, bit_depth always
// (Sendspin spec, Source messages — client-stream/start)
root["type"] = "client-stream/start";
JsonObject source_json = root["payload"]["source"].to<JsonObject>();
source_json["codec"] = to_cstr(msg->codec);
source_json["channels"] = msg->channels;
source_json["sample_rate"] = msg->sample_rate;
source_json["bit_depth"] = msg->bit_depth;
if (msg->codec_header.has_value()) {
source_json["codec_header"] = msg->codec_header.value();
}
Comment thread
chrisuthe marked this conversation as resolved.

std::string output;
serializeJson(doc, output);
return output;
}

std::string format_client_stream_end_message() {
// Every message carries a payload object, empty when the message defines no fields
// (Sendspin spec, Message Format); a literal, pinned by the exact-string unit test
return R"({"type":"client-stream/end","payload":{}})";
}

std::string format_stream_request_format_message(const StreamRequestFormatMessage* msg) {
(void)msg;

Expand Down
130 changes: 120 additions & 10 deletions src/protocol_messages.h
Original file line number Diff line number Diff line change
Expand Up @@ -47,32 +47,60 @@ namespace sendspin {
enum SendspinBinaryRole : uint8_t {
SENDSPIN_ROLE_PLAYER = 1, // 000001xx (IDs 4-7)
SENDSPIN_ROLE_ARTWORK = 2, // 000010xx (IDs 8-11)
SENDSPIN_ROLE_SOURCE = 3, // 000011xx (IDs 12-15), outbound-only: never inbound-dispatched
};

/// @brief Extracts the role field from a standard 4-slot binary message type byte
/// @param type Binary message type byte.
/// @return Role portion of the type (bits 7-2).
/// @warning Valid only for the standard 4-slot roles (PLAYER/ARTWORK, IDs 4-11). The visualizer
/// range (IDs 16-23) is dispatched by range in SendspinClient::process_binary_message
/// and must not be routed through this helper: get_binary_role(16) yields 4, which
/// matches no SendspinBinaryRole enumerator.
/// @warning Valid only for the standard 4-slot roles (PLAYER/ARTWORK IDs 4-11, SOURCE IDs
/// 12-15). The visualizer range (IDs 16-23) is dispatched by range in
/// SendspinClient::process_binary_message and must not be routed through this helper:
/// get_binary_role(16) yields 4, which matches no SendspinBinaryRole enumerator.
inline uint8_t get_binary_role(uint8_t type) {
return type >> 2;
}
/// @brief Extracts the slot field from a standard 4-slot binary message type byte
/// @param type Binary message type byte.
/// @return Slot portion of the type (bits 1-0).
/// @warning Valid only for the standard 4-slot roles (PLAYER/ARTWORK, IDs 4-11). It masks bits
/// 1-0, so it cannot address the visualizer's 8-slot range (e.g. IDs 16 and 20 both
/// alias to slot 0); those messages are dispatched by range, not by slot.
/// @warning Valid only for the standard 4-slot roles (PLAYER/ARTWORK IDs 4-11, SOURCE IDs
/// 12-15). It masks bits 1-0, so it cannot address the visualizer's 8-slot range (e.g.
/// IDs 16 and 20 both alias to slot 0); those messages are dispatched by range, not by
/// slot.
inline uint8_t get_binary_slot(uint8_t type) {
return type & 0x03;
}

/// @brief Size of the big-endian 64-bit timestamp that follows the type byte in binary messages
inline constexpr size_t BINARY_TIMESTAMP_SIZE = 8;

/// @brief Decodes a big-endian 64-bit value to host byte order
/// @param bytes Pointer to at least BINARY_TIMESTAMP_SIZE bytes.
/// @return The decoded value as a signed 64-bit integer.
inline int64_t be64_to_host(const uint8_t* bytes) {
uint64_t val = 0;
for (size_t i = 0; i < BINARY_TIMESTAMP_SIZE; ++i) {
val = (val << 8) | bytes[i];
}
return static_cast<int64_t>(val);
}

/// @brief Encodes a host 64-bit value as big-endian bytes
/// @param value Value to encode.
/// @param bytes [out] Destination for BINARY_TIMESTAMP_SIZE bytes.
inline void host_to_be64(int64_t value, uint8_t* bytes) {
const auto val = static_cast<uint64_t>(value);
for (size_t i = 0; i < BINARY_TIMESTAMP_SIZE; ++i) {
bytes[i] = static_cast<uint8_t>(val >> ((BINARY_TIMESTAMP_SIZE - 1 - i) * 8U));
}
}

/// @brief Binary message type byte values for known message kinds
enum SendspinBinaryType : uint8_t {
SENDSPIN_BINARY_PLAYER_AUDIO = 4, // Player slot 0: encoded audio chunk
SENDSPIN_BINARY_ARTWORK_IMAGE = 8, // Artwork slot 0: image data
SENDSPIN_BINARY_PLAYER_AUDIO = SENDSPIN_ROLE_PLAYER << 2, // Player slot 0: encoded audio
SENDSPIN_BINARY_ARTWORK_IMAGE = SENDSPIN_ROLE_ARTWORK << 2, // Artwork slot 0: image data
SENDSPIN_BINARY_SOURCE_AUDIO = SENDSPIN_ROLE_SOURCE << 2, // Source slot 0: encoded audio
// chunk (client->server)
// Visualizer expanded allocation (IDs 16-23); each data type is its own message
// carrying exactly one frame of [timestamp:8][data]
SENDSPIN_BINARY_VISUALIZER_LOUDNESS = 16, // uint16 A-weighted loudness
Expand Down Expand Up @@ -105,6 +133,7 @@ enum class SendspinRole : uint8_t {
ARTWORK, // Album artwork role
VISUALIZER, // Audio visualization role
COLOR, // Audio-derived color palette role
SOURCE, // Audio capture role (streams to the server)
};

/// @brief Converts a SendspinRole value to its protocol wire string representation
Expand All @@ -124,6 +153,8 @@ inline const char* to_cstr(SendspinRole role) {
return "visualizer@v1";
case SendspinRole::COLOR:
return "color@v1";
case SendspinRole::SOURCE:
return "source@v1";
default:
return "unknown";
}
Expand Down Expand Up @@ -605,6 +636,53 @@ struct ServerColorStateDelta {
std::optional<std::optional<RgbColor>> on_light;
};

// --- source role ---

/// @brief Commands addressed to the source role in server/command messages
enum class SourceCommand : uint8_t {
START, // Begin capturing and streaming audio to the server
STOP, // Stop capturing and streaming audio
};

inline std::optional<SourceCommand> source_command_from_string(const std::string& str) {
if (str == "start") {
return SourceCommand::START;
}
if (str == "stop") {
return SourceCommand::STOP;
}
return std::nullopt;
}

/// @brief Line-input signal state reported by the source role in client/state messages
enum class SourceSignal : uint8_t {
PRESENT, // Audio signal detected on the capture input
ABSENT, // No audio signal on the capture input
};

inline const char* to_cstr(SourceSignal signal) {
switch (signal) {
case SourceSignal::PRESENT:
return "present";
case SourceSignal::ABSENT:
default:
return "absent";
}
}

/// @brief Source capabilities advertised to the server during the hello handshake
struct SourceSupportObject {
bool line_sense{false};
};

/// @brief Source state reported by the client to the server in client/state messages
///
/// The signal field is only meaningful when line_sense was advertised in the hello; the object
/// itself may be present and empty (Sendspin spec, Source messages — Client state object).
struct ClientSourceStateObject {
std::optional<SourceSignal> signal{};
};

// ============================================================================
// Message envelope structs
// ============================================================================
Expand All @@ -619,12 +697,27 @@ struct ClientHelloMessage {
std::optional<PlayerSupportObject> player_v1_support{};
std::optional<ArtworkSupportObject> artwork_v1_support{};
std::optional<VisualizerSupportObject> visualizer_support{};
std::optional<SourceSupportObject> source_v1_support{};
};

/// @brief Outgoing client/state message reporting client playback state to the server
/// @brief Outgoing client/state message reporting client availability and role state to the server
struct ClientStateMessage {
SendspinClientState state{};
std::optional<ClientPlayerStateObject> player{};
std::optional<ClientSourceStateObject> source{};
};

/// @brief Outgoing client-stream/start message announcing the source's outbound audio format
///
/// Mirrors the AudioSupportedFormatObject field set. codec_header is required for flac and
/// absent for pcm and opus (Sendspin spec, Source messages -- codec framing); the invariant is
/// the producer's contract, upheld by the role's config validation rather than checked here.
struct ClientStreamStartMessage {
SendspinCodecFormat codec{};
uint8_t channels{};
uint32_t sample_rate{};
uint8_t bit_depth{};
std::optional<std::string> codec_header{};
};

/// @brief Parsed server/hello handshake message received at connection startup
Expand Down Expand Up @@ -706,6 +799,14 @@ void apply_group_update_deltas(GroupUpdateObject* current, const GroupUpdateObje
/// @return true if parsing succeeded, false on missing required fields.
bool process_server_command_message(JsonObject root, ServerCommandMessage* cmd_msg);

/// @brief Parses the source section of a server/command JSON message
/// @param root Parsed JSON object from the message.
/// @param source_cmd [out] The parsed source command.
/// @return true if the message carried a source object with a valid command; false when the
/// section is absent or rejected (a source object with a missing or invalid command is
/// rejected as a whole).
bool process_server_command_source(JsonObject root, SourceCommand* source_cmd);

/// @brief Parses the metadata section of a server/state JSON message
///
/// The server/state sections are parsed individually rather than into one aggregate struct: the
Expand Down Expand Up @@ -774,6 +875,15 @@ std::string format_client_hello_message(const ClientHelloMessage* msg);
/// @return State message serialized into JSON format.
std::string format_client_state_message(const ClientStateMessage* msg);

/// @brief Formats a client-stream/start message as a JSON string for sending to the server
/// @param msg Message to serialize.
/// @return Stream start message serialized into JSON format.
std::string format_client_stream_start_message(const ClientStreamStartMessage* msg);

/// @brief Formats a client-stream/end message as a JSON string for sending to the server
/// @return Stream end message serialized into JSON format.
std::string format_client_stream_end_message();

/// @brief Formats a stream/request_format message as a JSON string for sending to the server
/// @param msg Message to serialize.
/// @return Stream request format message serialized into JSON format.
Expand Down
12 changes: 2 additions & 10 deletions src/visualizer_role.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ static const char* const TAG = "sendspin.visualizer";
// each entry costs an aligned per-entry ItemHeader, so effective wire-data capacity is smaller
// (see the buffer_capacity note in config.h).
static constexpr size_t ENTRY_TYPE_SIZE = 1;
static constexpr size_t TIMESTAMP_SIZE = 8;
static constexpr size_t TIMESTAMP_SIZE = sendspin::BINARY_TIMESTAMP_SIZE;

// Minimum payload bytes after the timestamp, per wire message type
static constexpr size_t LOUDNESS_PAYLOAD_SIZE = 2; // uint16 value
Expand Down Expand Up @@ -77,14 +77,6 @@ static constexpr int64_t TOO_OLD_THRESHOLD_US = 20000; // 20ms
// Big-endian helpers
// ============================================================================

static int64_t read_be64(const uint8_t* p) {
uint64_t val = 0;
for (int i = 0; i < 8; ++i) {
val = (val << 8) | p[i];
}
return static_cast<int64_t>(val);
}

static uint16_t read_be16(const uint8_t* p) {
return static_cast<uint16_t>(p[0]) << 8 | static_cast<uint16_t>(p[1]);
}
Expand Down Expand Up @@ -540,7 +532,7 @@ void VisualizerRole::Impl::drain_thread_func(VisualizerRole::Impl* self) {
}
auto* raw = static_cast<const uint8_t*>(item);
uint8_t wire_type = raw[0];
int64_t server_ts = read_be64(raw + ENTRY_TYPE_SIZE);
int64_t server_ts = be64_to_host(raw + ENTRY_TYPE_SIZE);
int64_t client_ts = self->client->get_client_time(server_ts);

if (client_ts == 0) {
Expand Down
Loading
Loading