This guide describes what you need to implement in order to integrate sendspin-cpp into your application. The library provides the Sendspin protocol, audio decoding, and time synchronization. You provide the audio output, network readiness, and optional persistence.
Integration follows this pattern:
- Create a
SendspinClientwith a configuration struct - Add roles (player, controller, metadata, artwork, visualizer, color, source) depending on what your application needs
- Implement listener interfaces for the roles you added
- Implement a network provider (required) and optionally a persistence provider
- Wire listeners and providers to the client and roles
- Start the server and run the main loop
The only role with a required callback is the player role (on_audio_write). All other listener methods have default no-op implementations.
Include sendspin/client.h for the client class, config types, and shared types. Role headers must be included explicitly for any roles you use:
#include "sendspin/client.h" // SendspinClient, providers, listeners
#include "sendspin/player_role.h" // PlayerRole, PlayerRoleListener
#include "sendspin/controller_role.h" // ControllerRole, ControllerRoleListener
#include "sendspin/metadata_role.h" // MetadataRole, MetadataRoleListener
#include "sendspin/artwork_role.h" // ArtworkRole, ArtworkRoleListener
#include "sendspin/visualizer_role.h" // VisualizerRole, VisualizerRoleListener
#include "sendspin/color_role.h" // ColorRole, ColorRoleListener
#include "sendspin/source_role.h" // SourceRole, SourceRoleListenerOnly include the role headers you need. client.h includes sendspin/config.h (all configuration structs, including SendspinClientConfig) and sendspin/types.h transitively.
using namespace sendspin;
// Optional: set log level before creating the client (host builds only, no-op on ESP-IDF)
SendspinClient::set_log_level(LogLevel::INFO);
SendspinClientConfig config;
config.name = "Living Room Speaker"; // Friendly display name
config.product_name = "My Speaker"; // Device product name (optional)
config.manufacturer = "My Company"; // Manufacturer name (optional)
config.software_version = "1.0.0"; // Software version string (optional)
SendspinClient client(std::move(config));client_id is not a configuration field. The library derives it automatically from the
static X25519 keypair (base64url(public_key), 43 characters). The keypair is generated
on first boot and, when a SendspinPersistenceProvider is set, persisted so that the same
identity survives reboots. After start() the derived client_id is available
via client.client_id().
The client_id uniquely identifies this device to Sendspin servers and must be stable
across reboots for pairing and server-preference to work correctly. Without a persistence
provider the keypair is regenerated on every boot (development use only).
Add only the roles your application needs. All roles must be added before calling start().
The player role handles audio decoding and synchronized playback. It requires a configuration struct that declares which audio formats your hardware supports.
PlayerRoleConfig player_config;
player_config.audio_formats = {
{SendspinCodecFormat::FLAC, 2, 44100, 16},
{SendspinCodecFormat::FLAC, 2, 48000, 16},
#ifdef SENDSPIN_ENABLE_OPUS
{SendspinCodecFormat::OPUS, 2, 48000, 16},
#endif
{SendspinCodecFormat::PCM, 2, 44100, 16},
{SendspinCodecFormat::PCM, 2, 48000, 16},
};
player_config.audio_buffer_capacity = 1000000; // Encoded audio held, in bytes (default: 1000000)
player_config.fixed_delay_us = 0; // Fixed delay offset in microseconds
player_config.initial_output_delay_ms = 0; // Initial user-adjustable delay
player_config.extra_startup_silence_ms = 50; // Extra startup silence for decode headroom (default: 50)
// player_config.required_lead_time_ms = 150; // Startup lead requested from the server;
// unset derives it from the pipeline
player_config.min_buffer_ms = 500; // Ongoing buffer requested from the server (default: 500)
auto& player = client.add_player(std::move(player_config));Each AudioSupportedFormatObject declares a codec/channels/sample_rate/bit_depth combination. The server selects from these when establishing an audio stream. The list must include at least one FLAC or PCM entry, since those are the codecs every server supports; client.start() fails and logs if it does not. OPUS is optional and needs a build with the Opus decoder (SENDSPIN_ENABLE_OPUS, on by default); client.start() also fails on an OPUS entry without it.
The stream parameters negotiated by the server are available via get_current_stream_params(), which returns a ServerPlayerStreamObject with these fields:
| Field | Type | Description |
|---|---|---|
codec |
std::optional<SendspinCodecFormat> |
Audio codec |
sample_rate |
std::optional<uint32_t> |
Sample rate in Hz |
channels |
std::optional<uint8_t> |
Number of channels |
bit_depth |
std::optional<uint8_t> |
Bits per sample |
Call is_complete() on the object to check that codec, sample_rate, channels, and
bit_depth all have values. A FLAC stream's codec header goes to the decoder and is not
part of the object.
Lets your application send transport commands (play, pause, next, etc.) and receive the server's controller state (volume, mute, repeat, shuffle, supported commands).
auto& controller = client.add_controller();Receives track metadata (title, artist, album, progress, etc.) from the server. Takes an optional configuration struct with a flag per text field, all kept by default; a field turned off reaches the listener without a value and costs no storage.
auto& metadata = client.add_metadata(); // Every text field
// Or only what the display shows:
// auto& metadata = client.add_metadata({.album_artist = false, .album = false, .artwork_url = false});Receives album artwork images from the server. Requires a configuration struct declaring preferred image formats per slot.
ArtworkRoleConfig artwork_config;
artwork_config.preferred_formats = {
{SendspinImageSource::ALBUM, SendspinImageFormat::JPEG, 300, 300},
};
auto& artwork = client.add_artwork(std::move(artwork_config));The slot/channel number for each entry is its position (index) in preferred_formats; the first entry is slot 0, the second slot 1, and so on. Up to ARTWORK_MAX_SLOTS (4) entries are supported. The client reports these channels to the server in its client/state artwork object, which is sent once the server activates the role.
Each image arrives as several binary messages that the role reassembles, so on_image_decode() fires once per complete image, never per message. ImageSlotPreference::max_image_bytes is the channel's memory budget: the role refuses an image the server announces as larger than that (the channel keeps what it was showing and the refusal is logged). While the client runs, the role holds one buffer of that size per configured channel, allocated by start() and released by stop(), which each image is copied into as its messages arrive; the messages themselves arrive in the client's shared inbound ring (placed by SendspinClientConfig::inbound_ring_location), which reserves room for one image of that size per channel in flight and returns each message as soon as it is copied. On a channel with require_frame_done, an image that completes while the channel's last delivery is un-acked waits in that buffer until frame_done() reopens the gate; a newer image replaces it there. The default, 128 KiB, suits the channel sizes a display client of this class asks for; raise it for a channel whose images are genuinely larger.
Receives real-time beat, loudness, dominant-frequency, onset, and spectrum data synchronized to playback.
VisualizerSupportObject vis_support;
vis_support.buffer_capacity = 32768; // Inbound ring bytes held; ~1/7 is advertised as wire data
VisualizerStreamConfig vis_stream;
vis_stream.types = {
VisualizerDataType::BEAT,
VisualizerDataType::LOUDNESS,
VisualizerDataType::F_PEAK,
VisualizerDataType::SPECTRUM,
VisualizerDataType::PEAK,
};
vis_stream.rate_max = 30; // Set to the display refresh rate
vis_stream.spectrum = VisualizerSpectrumConfig{
.n_disp_bins = 32,
.scale = VisualizerSpectrumScale::MEL,
.f_min = 40,
.f_max = 16000,
};
auto& visualizer = client.add_visualizer({.support = vis_support, .stream = vis_stream});support is the capacity the client advertises once in client/hello; stream is the
configuration it reports in client/state, from which the server derives the stream it
sends. Both are set at construction time, so the stream configuration is reported as
configured and does not change while the client runs.
Receives an RGB color palette derived by the server from the currently playing audio (e.g., extracted from album artwork). Useful for LED matrices, status lights, or themed displays. Server-to-client only; no configuration.
auto& color = client.add_color();Streams audio captured on the device (a line input or a microphone) to the server. The server decides when: its start command opens the stream and its stop closes it (roles/source/v1.md "Source command semantics"). The SourceRoleConfig passed to add_source() is the format of every stream the role opens:
SourceRoleConfig source_config;
source_config.sample_rate = 48000;
source_config.channels = 2;
source_config.bit_depth = 16;
source_config.codec = SendspinCodecFormat::PCM; // or FLAC, or OPUS with SENDSPIN_ENABLE_OPUS
auto& source = client.add_source(source_config);An invalid config (see SourceRoleConfig) leaves the role inert; a valid config whose buffers cannot be allocated fails client.start() instead. A FLAC or OPUS role streams its codec only to a server whose server/hello lists it, and the same capture as PCM otherwise.
Feed the capture to write_audio() from one capture thread, with the capture time of each buffer's first sample on the client's clock (SendspinClient::now_us()), or 0 to have the write stamped as ending now:
source.write_audio(pcm_bytes, len, capture_time_us);It never waits or allocates. It refuses audio while the stream is closed, a write that is not a whole number of frames, a write longer than the capture buffer takes at once (see capture_buffer_ms in SourceRoleConfig), and a write that finds the capture buffer full; after a stall the stream resumes from live capture rather than sending the stale backlog. Start capturing in on_streaming_started() and stop in on_streaming_stopped() (see SourceRoleListener); the client holds high-performance networking (on_request_high_performance()) between the two, as it does during playback. Stop calling write_audio() before destroying the client.
A role configured with line_sense reports the capture input's signal state from the main loop thread, which goes to the server in client/state:
source.set_signal(SourceSignal::PRESENT); // or ABSENTDetecting the signal is up to the application (a jack-detect pin, a link state, or a level meter on the input); call set_signal() on each change. The server decides what to do with it (roles/source/v1.md "Default streaming behavior").
examples/source_client/ is a working reference: it captures the default PortAudio input device and feeds write_audio() from the capture callback with each buffer's capture time on the client's clock. With -s it also reports line sense from the input's level.
An application can advertise roles of its own, outside the specification (README.md "Application-Specific Roles"), through SendspinClientConfig::application_roles. Each is listed in client/hello supported_roles after the library's roles, and its support object, if any, is sent as "<role>_support":
config.application_roles.push_back({"_acme_display@v1", std::string(R"({"port":8930})")});
config.application_roles.push_back({"_acme_lights@v1", std::nullopt}); // no support objectA role name is a leading _, a name, @ and a version, in visible ASCII other than " and \, and at most 64 bytes, a library limit the specification does not set; include a vendor prefix to avoid collisions. support_json is serialized JSON and must be exactly one object, nested at most 8 levels deep (the object itself is level 1) so the hello stays within the 10 levels messaging.md "client/hello" allows; start() parses it and sends the parser's own serialization, so lenient input such as an unquoted key goes out as standard JSON. A string that is not UTF-8, or holds a control character the serializer would write back unescaped (a raw one, or an escape such as \u0001), is refused. An escaped surrogate outside a valid pair is dropped or replaced by another character, and a number beyond double range goes out as null, so keep to plain values. The whole client/hello, support objects included, must also fit the 16384 bytes messaging.md "client/hello" allows; start() builds one to check, counting a MAC address the platform may detect only later. client.start() logs an error and returns false on a malformed name, a name listed twice, a support object that does not meet those rules, or a hello over that size. The library only announces these roles: the server may list one in active_roles, and what that means, and any messages it carries, is up to the application.
The library does not report which application roles the server activates: it reads active_roles into the set of its own roles plus whether the list has any entry, so an application that needs to know learns it through its own channel. An activated application role still makes active_roles non-empty, so on a connection that is not playback-capable (messaging.md "server/activate", "Playback-capable connections") the client closes with unauthorized, or with pairing_required when enabling unpaired access would have admitted it, just as it does for one of its own roles.
A role you add is configured and ready, but only the server decides which roles a session actually
uses, and it may change that set at any time. When an activation removes a role, the library tears
that role down on the spot: a stream role stops its output, drops its buffers, and reports the end
(on_stream_end(), on_visualizer_stream_end(), on_image_clear() for every slot; the source
closes its open input stream with client-stream/end and reports on_streaming_stopped()), and a
state role drops its state and reports the clear (on_metadata_clear(), on_color_clear(),
on_controller_state_clear()). The connection stays up and the other roles keep running. A clear
callback is therefore not proof that the server is gone; treat it as "this role has nothing to
show" and make it idempotent. Until the server adds the role back, anything it still sends for
that role is ignored rather than acted on, so a removed role stays quiet; when it is added back,
the role resumes normally.
The on_audio_write method is one of only two pure virtual (required) methods in the library;
the other is SendspinNetworkProvider::is_network_ready().
struct MyPlayerListener : PlayerRoleListener {
// REQUIRED: Write decoded PCM audio (interleaved, little-endian) to your audio output.
// Called from a background thread. May block up to timeout_ms.
// Must return the number of bytes actually written.
size_t on_audio_write(uint8_t* data, size_t length, uint32_t timeout_ms) override {
return my_audio_output.write(data, length, timeout_ms);
}
// Optional: Called when a new audio stream starts.
// Use this to configure your audio output with the new stream parameters.
void on_stream_start() override {
auto& params = player_ref.get_current_stream_params();
my_audio_output.configure(*params.sample_rate, *params.channels, *params.bit_depth);
}
// Optional: Called when the audio stream ends.
void on_stream_end() override {
my_audio_output.clear();
}
// Optional: Called when the server changes the volume.
void on_volume_changed(uint8_t volume) override {
my_audio_output.set_volume(volume);
}
// Optional: Called when the server changes the mute state.
void on_mute_changed(bool muted) override {
my_audio_output.set_muted(muted);
}
// Optional: Called when the server changes the output delay.
void on_output_delay_changed(uint16_t delay_ms) override { }
};Your audio output must report back when audio frames have been played. This feedback drives the library's synchronization. Call notify_audio_played() from your audio output callback:
// In your audio output's playback callback (e.g., PortAudio callback):
player.notify_audio_played(frames_played, current_timestamp_us);frames_played: Number of audio frames (not bytes) just playedtimestamp: Client timestamp in microseconds when the audio will finish playing (e.g., fromSendspinClient::now_us())
This method is thread-safe and is expected to be called from an audio callback thread.
struct MyMetadataListener : MetadataRoleListener {
void on_track_metadata(const TrackMetadata& md) override {
// Overwrite display state on every call so server clears (nullopt) propagate.
display_title(md.title.value_or(""));
display_artist(md.artist.value_or(""));
display_album(md.album.value_or(""));
if (md.progress) {
update_progress_bar(md.progress->track_progress, md.progress->track_duration);
} else {
clear_progress_bar();
}
}
};The TrackMetadata contains these fields (all optional except timestamp):
| Field | Type | Description |
|---|---|---|
timestamp |
int64_t |
Server clock µs at which this metadata becomes valid; delivery is held until the synced client clock reaches it |
title |
std::optional<std::string_view> |
Track title, at most 255 bytes |
artist |
std::optional<std::string_view> |
Track artist, at most 255 bytes |
album_artist |
std::optional<std::string_view> |
Album artist, at most 255 bytes |
album |
std::optional<std::string_view> |
Album name, at most 255 bytes |
artwork_url |
std::optional<std::string_view> |
Artwork URL, at most 2048 bytes |
year |
std::optional<uint16_t> |
Release year |
track |
std::optional<uint16_t> |
Track number |
progress |
std::optional<MetadataProgressObject> |
Playback progress (see below) |
MetadataProgressObject contains track_progress (ms), track_duration (ms), and playback_speed.
The text views are valid for the duration of the callback: copy a field the listener needs after it returns. The text fields are bounded in UTF-8 bytes as roles/metadata/v1.md "server/state metadata object" caps them. A server that sends a longer one has it cut to the cap at a UTF-8 character boundary, with one warning per connection. A text field turned off in MetadataRoleConfig is always nullopt.
Every on_track_metadata() call carries the full state of the track being described, not a set of changes: a field the server left out of that update is nullopt, whatever an earlier update reported for it, and a state without progress means there is no position to show. Listeners that mirror metadata into display state should therefore overwrite every displayed value on each call (using e.g. value_or("")) rather than merging into what they already show.
You can also poll track progress at any time:
uint32_t progress_ms = metadata.get_track_progress_ms(); // Interpolated
uint32_t duration_ms = metadata.get_track_duration_ms(); // 0 = unknown/livestruct MyControllerListener : ControllerRoleListener {
void on_controller_state(const ServerStateControllerObject& state) override {
// Update UI with server-side volume, mute, repeat, and shuffle state
update_volume_slider(state.volume);
update_mute_button(state.muted);
update_repeat_icon(state.repeat);
update_shuffle_icon(state.shuffle);
// Enable/disable buttons based on supported commands
enable_buttons(state.supported_commands);
}
};The artwork role uses a dedicated decode thread for the CPU-bound decode step and the main loop for scheduled display. on_image_decode() fires on the decode thread immediately when encoded image data arrives; once decode returns, the server display timestamp is handed off to the main loop, which fires on_image_display() once the timestamp is reached. If a newer frame for the same slot finishes decoding before its predecessor's display fires, only the newer one is delivered. Lifecycle callbacks also fire on the main loop thread.
on_image_display() reports lateness_ms: how far past the (offset-shifted) deadline it fired. Displays are best-effort, so an image that arrives or decodes after its deadline fires as soon as it is ready. Treat a small value as on time; a huge value is the cue to snap instantly. lateness_ms is 0 only when there is no connection (no deadline exists), so a connected on-time display always reports a small nonzero value.
struct MyArtworkListener : ArtworkRoleListener {
// THREAD SAFETY: Called from the dedicated decode thread.
// Decode the encoded image synchronously (e.g., JPEG to bitmap).
// The data pointer is valid for the duration of this call.
void on_image_decode(uint8_t slot, const uint8_t* data, size_t length,
SendspinImageFormat format) override {
decoded_images[slot] = decode_image(data, length, format);
}
// Called from the main loop thread once the server display timestamp is reached.
// lateness_ms reports how late the display fired (0 = no connection).
void on_image_display(uint8_t slot, uint32_t lateness_ms) override {
display.show_image(slot, decoded_images[slot]);
}
// Called from the main loop thread when artwork should be cleared, either for this slot
// alone or for every slot at the end of a stream.
void on_image_clear(uint8_t slot) override {
display.clear_slot(slot);
}
};Knowing when there is no artwork. Artwork stays valid until the server replaces or clears it, and the artwork role is independent of the metadata role, so a track change alone sends nothing: the next track of the same album keeps showing the image already delivered. When an item genuinely has no artwork, the server clears that channel and on_image_clear() fires for that slot alone, scheduled to its server timestamp like a display (display_offset_ms included) so it lands on the item boundary. on_image_clear() also fires for every configured slot on stream end, disconnect, and a server/activate that takes the artwork role out of the session's active roles.
| What happened | What the listener sees |
|---|---|
| Artwork unchanged (e.g. next track of the same album) | nothing; the current image stays valid |
| Item has no artwork | on_image_clear(slot) for that slot |
| Stream ended, connection lost, or the role deactivated | on_image_clear(slot) for every configured slot |
Cross-fades with back-pressure (opt-in). By default the role decodes and displays every frame as it arrives. A slot can instead opt into a back-pressure gate by setting ImageSlotPreference::require_frame_done. With the gate on, the role keeps at most one un-acked delivery (a frame or a clear) in flight for that slot. Call ArtworkRole::frame_done(slot) from the main loop exactly once for every on_image_display() and on_image_clear() that slot receives, e.g. once a cross-fade animation finishes. An extra call is a harmless no-op, but a missed one wedges the slot: there is no timeout, the acknowledgment is the contract.
Payloads and a stream end reach the gate differently:
- A frame or per-channel clear arriving while a delivery is un-acked is buffered latest-wins and delivered only after
frame_done(slot), and then owes its ownframe_done(). It waits behind the outstanding delivery rather than replacing it, so a consumer presenting a delivery is never interrupted. The exception is a delivery that has not reachedon_image_display()yet: the server announcing a newer image for the slot replaces it outright, its display never fires, and the gate reopens for the buffered payload. - A stream end is a lifecycle event, not a payload, so it is never buffered: it fires
on_image_clear()immediately for every configured slot, discards anything buffered, and replaces whatever delivery was outstanding. Exactly oneframe_done()is owed afterward whatever was in flight.
Pair the gate with ImageSlotPreference::display_offset_ms to start a fade before the track boundary (positive fires the display early, mirroring PlayerRoleConfig::fixed_delay_us), and use lateness_ms to shorten the fade so it still ends on schedule:
// Slot 0 has require_frame_done set, so on_image_display() starts a cross-fade and the gate
// stays held until on_fade_complete() acks it.
void on_image_display(uint8_t slot, uint32_t lateness_ms) override {
display.start_fade(slot, decoded_images[slot], FADE_MS - std::min(lateness_ms, FADE_MS));
}
void on_image_clear(uint8_t slot) override {
display.clear_slot(slot);
artwork_role->frame_done(slot); // a clear is a delivery; ack it
}
void on_fade_complete(uint8_t slot) {
artwork_role->frame_done(slot); // release the gate so the next frame can decode
}Call frame_done() from the main loop thread. It is a safe no-op when the slot has nothing un-acked (including slots where require_frame_done is false), so calling it from inside on_image_display()/on_image_clear() for an instant, non-animated swap is fine.
struct MyVisualizerListener : VisualizerRoleListener {
// THREAD SAFETY: Data callbacks fire on a dedicated drain thread at each
// frame's display timestamp, less VisualizerRoleConfig::display_offset_ms.
// A frame more than 20 ms past its delivery time is dropped, so copy
// data quickly and defer heavy processing.
void on_loudness(int64_t client_timestamp, uint16_t loudness) override {
update_vu_meter(loudness);
}
void on_f_peak(int64_t client_timestamp, uint16_t frequency_hz, uint16_t amplitude) override {
update_peak_display(frequency_hz, amplitude);
}
void on_spectrum(int64_t client_timestamp, const std::vector<uint16_t>& bins) override {
update_spectrum_bars(bins);
}
// Musical beat events; downbeat marks a bar start when the server tracks downbeats.
void on_beat(int64_t client_timestamp, bool downbeat) override {
trigger_beat_animation(downbeat);
}
// Energy onset (transient) events, independent of musical timing.
void on_peak(int64_t client_timestamp, uint8_t strength) override {
trigger_flash(strength);
}
// Called from the main loop thread.
void on_visualizer_stream_start(const ServerVisualizerStreamObject& stream) override { }
void on_visualizer_stream_end() override { }
void on_visualizer_stream_clear() override { }
};struct MyColorListener : ColorRoleListener {
void on_color(const ServerColorStateObject& c) override {
if (c.background_dark) set_dark_bg(*c.background_dark);
if (c.background_light) set_light_bg(*c.background_light);
if (c.primary) set_primary((*c.primary)[0], (*c.primary)[1], (*c.primary)[2]);
// accent, on_dark, on_light...
}
// Called when the cached colors are dropped: the connection was lost, or a
// server/activate took the color role out of the session's active roles.
// Reset any displayed colors to a neutral or default state.
void on_color_clear() override {
reset_to_defaults();
}
};The ServerColorStateObject contains a timestamp and six optional RgbColor fields (std::array<uint8_t, 3>, ordered [R, G, B]):
| Field | Description |
|---|---|
timestamp |
Server clock µs at which this color update becomes valid; delivery is held until the synced client clock reaches it, or fires immediately if there is no active connection |
background_dark |
Background suitable for dark mode; safe contrast with white text and on_dark |
background_light |
Background suitable for light mode; safe contrast with black text and on_light |
primary |
Dominant color, not adjusted for contrast |
accent |
Secondary or complementary color, not adjusted for contrast |
on_dark |
Light foreground for use on dark backgrounds |
on_light |
Dark foreground for use on light backgrounds |
Every on_color() call carries the full palette: a color the server left out of that update is nullopt, whatever an earlier update reported for it, so listeners render from the palette they are handed rather than merging it into the one they already hold.
Both callbacks are optional and fire on the main loop thread, in pairs:
struct MySourceListener : SourceRoleListener {
void on_streaming_started() override {
// client-stream/start was sent; write_audio() accepts audio from here on
capture.start();
}
void on_streaming_stopped() override {
// A server stop, the role's removal, the client becoming unavailable, the connection
// ending, or the client stopping; write_audio() refuses audio again
capture.stop();
}
};The library needs to know when the network is available. This is the only required provider.
struct MyNetworkProvider : SendspinNetworkProvider {
bool is_network_ready() override {
return wifi_is_connected(); // Your platform's network check
}
};On host platforms where the network is always available, return true:
struct HostNetworkProvider : SendspinNetworkProvider {
bool is_network_ready() override { return true; }
};is_network_ready() is called on the main loop only, like the persistence provider: once from
start(), then from every loop() while the client runs. A server the network kept down at
start() starts listening on the protocol task right after the loop() that finds the network
ready. Keep it cheap and non-blocking (a read of a flag the platform keeps current), and do not
call back into the client from it.
Allows the library to persist state across reboots. Required for stable identity and
pairing. On host platforms examples/common/file_persistence_provider.h provides
FilePersistenceProvider, which persists to a single JSON file (one document mapping each
key below to base64url(bytes)) -- use it directly or as a reference implementation.
The interface is a plain byte-blob store: a load, a save, and a commit, independent of what is being stored:
class SendspinPersistenceProvider {
public:
virtual std::optional<std::vector<uint8_t>> load_blob(const std::string& key);
virtual bool save_blob(const std::string& key, const uint8_t* data, size_t len);
virtual bool commit();
};The library owns all serialization. It calls these three methods with one of the fixed keys below; a provider never needs to parse or interpret the bytes, only store and return them byte-for-byte.
Every method is invoked on the main loop thread, for every key, so a provider needs no locking
of its own. (A persisted change decided on the protocol task, such as the pairing record
committed when a pairing finalizes, an unpair, or a playback handoff, is staged internally and
written from the next loop() call.)
No internal library lock is held across the call, so a slow write does not stall the audio path,
a Noise handshake or any other connection work -- but it does stop the main loop for its
duration, so the call must be one bounded storage operation, and it must not call back into the
client.
Provisioning writes from inside start() rather than in response to a runtime event:
save_blob(persistence_keys::KEYPAIR, ...) when no valid keypair is stored, and
save_blob(persistence_keys::PAIRING_PSK, ...) when no Pairing PSK is stored and none is
configured.
Every key comes from the persistence_keys namespace (sendspin/persistence_keys.h, which
sendspin/client.h includes), at most 12
characters (comfortably under a typical NVS key's 15-character limit). A provider must not invent its own keys; it only needs to
store and return whatever bytes the library gives it for each of these:
Every blob has a fixed size, published beside its key as a *_SIZE constant. The library
always writes exactly that many bytes and treats a stored blob of any other length as absent, so
a provider can store each key as a fixed-size value. RECORD_ORDER is the exception: it has no
size constant, is written as exactly max_pairing_records bytes, after the clamp to 5..255
described under Record capacity, and is read at any length. Integers are in the device's native
byte order: a blob is only ever read back by the device that wrote it.
| Key | Size | Contents |
|---|---|---|
persistence_keys::KEYPAIR |
KEYPAIR_SIZE (32) |
The static X25519 private key. |
persistence_keys::record_slot_key(n) |
RECORD_SLOT_SIZE (64) |
ONE SendspinPairingRecord as a codec blob (encode_pairing_record() / decode_pairing_record() in sendspin/persistence_codec.h), or 64 zero bytes when slot n is free. n runs from 0 to max_pairing_records - 1; the key is absent until that slot is first filled. |
persistence_keys::RECORD_ORDER |
max_pairing_records (12 by default) |
The occupied slot numbers, least recently used first, one byte each, then 0xFF in every remaining position. Decides which record a pairing at capacity evicts. |
persistence_keys::PAIRING_PSK |
PAIRING_PSK_SIZE (32) |
The stored SendspinPairingPsk as a codec blob (encode_pairing_psk() / decode_pairing_psk()). Never written while SendspinClientConfig::pairing_psk is set, which outranks a stored one. |
persistence_keys::LAST_PLAYED |
LAST_PLAYED_SIZE (32) |
The X25519 public key of the last-playback server, the key its base64url server_id encodes. |
persistence_keys::OUTPUT_DELAY |
OUTPUT_DELAY_SIZE (2) |
A uint16_t: the player's output delay in milliseconds. |
sendspin/persistence_codec.h is public so a custom provider (or a test) can inspect or seed
the record slot / PAIRING_PSK content in exactly the format the library itself
produces -- it is not something a provider hand-rolls its own version of. A record is its PSK
and the server's public key; a Pairing PSK blob is the bare PSK. Neither stores its
psk_id, which decoding derives from the PSK.
Only the keys a change actually touches are written: a pairing writes one slot (and the order), a revocation zeroes one slot (and writes the order), and a playback handoff that reorders recency writes only the order.
save_blob()returningtruemeans the write was accepted: stored, or queued until the nextcommit(). That includes the zeroed write that frees a record slot. Afalsereturn is reported, not retried: the in-memory state stays authoritative for the current boot. What the rejection costs decides the level: a write that changes which records the next boot holds logs a warning naming the key and what will be lost (or come back) at the next reboot, while a write the next boot rebuilds by itself (the recency order inRECORD_ORDER) reports at debug. For a record slot specifically: a rejected write of a just-paired record leaves the pairing working for this boot only (on_pairedstill fires; the record is gone after a reboot), and a rejected write of a removal means the store still holds the old record and will hand it back at the next boot, silently making the revoked PSK valid again (the revoked record is always dropped from RAM regardless of the return value). A supersede and an eviction both write the new record over the old one's slot, so a rejected write there leaves the OLD record in storage: after a reboot the client is paired to the server it evicted, or holds the pre-supersede PSK for a server that has already discarded it, which then falls back to unpaired (Sentinel) access.commit()makes every accepted write durable. The library calls it once after a batch of accepted writes that holds pairing material (KEYPAIR,PAIRING_PSK, or a record slot) and, for a pairing, does so beforeon_pairedfires. Writes toRECORD_ORDER,LAST_PLAYEDandOUTPUT_DELAYnever trigger it, so a provider that queues its writes may batch those on its own schedule. A provider that writes through insave_blob()keeps the default, which returnstrue. Afalsereturn is logged like a rejected write and not retried.- The library never erases a key: every blob it owns is rewritten in place or left alone (a removal zeroes its slot). An application that wipes the keyspace, for example on a factory reset, does so against its own store.
The library's built-in RecordStore caps the number of long-term records it will hold at
SendspinClientConfig::max_pairing_records, which defaults to
SendspinClientConfig::DEFAULT_MAX_PAIRING_RECORDS (12). The cap is also the number of record
slot keys the store may use, one per record. A pairing at the cap evicts the least recently used
record that no open connection is resolving against, since a pairing never fails for lack of
record storage. A record counts as recently used when a server takes playback on it, not when
it merely connects, and a new record starts as the most recently used. Replacing a record already held for
a given psk_id or server_id evicts nothing, because that never grows the store. Recency survives a reboot: it is what
persistence_keys::RECORD_ORDER holds. An evicted server's next handshake lands in the Sentinel
fallback, where it can offer its operator re-pairing. The protocol requires room for at least 5
records, so a smaller configured cap is raised to that floor, and a cap above 255 is lowered to
that ceiling (so the highest slot is 254, leaving byte value 255 free as the order blob's
padding). Raise or lower the cap by setting max_pairing_records before calling start():
SendspinClientConfig config;
config.max_pairing_records = 32;Every method has a default no-op / nullopt implementation, so you can implement only the
keys your deployment actually needs. The minimum useful set for a deployed device is
persistence_keys::KEYPAIR (for stable identity) and the record slot keys (for pairing to
survive reboots).
struct MyPersistenceProvider : SendspinPersistenceProvider {
std::optional<std::vector<uint8_t>> load_blob(const std::string& key) override {
std::vector<uint8_t> bytes;
if (nvs_read_bytes(key.c_str(), bytes)) return bytes;
return std::nullopt;
}
bool save_blob(const std::string& key, const uint8_t* data, size_t len) override {
return nvs_write_bytes(key.c_str(), data, len);
}
};A provider backed by a single flat NVS namespace (as above) can often implement the whole
interface generically, since every key is already sized to fit and the library handles
serialization. A provider on a store of fixed-size values (such as ESPHome's preferences) can
size each key from its *_SIZE constant, and RECORD_ORDER from the clamped
max_pairing_records; a provider that needs different backing per key (e.g. a plaintext-secrets
file plus separate flash-wear-optimized storage for OUTPUT_DELAY) can switch on key instead.
Receives client-level events. All callbacks fire on the main loop thread.
struct MyClientListener : SendspinClientListener {
// Called when the server's group/update messages are applied, with only the fields changed
// by those since the last call (playback state, group id, group name); client.group_state()
// returns the merged state. The views are valid for the duration of the callback. An empty
// group_id means the group was cleared. A non-empty server group_id that is not 43 base64url
// characters arrives as base64url of its SHA-256, which still names one group; a future
// release closes the connection on such an id instead.
void on_group_state(const GroupState& changes) override {
if (changes.playback_state) update_playback_indicator(*changes.playback_state);
if (changes.group_name) update_group_display(*changes.group_name);
}
// Called after a time sync burst completes.
void on_time_sync_updated(float error) override {
log_sync_quality(error);
}
// Called when the library needs low-latency networking (e.g., during active streaming).
// Use this to disable WiFi power saving on ESP32.
void on_request_high_performance() override {
esp_wifi_set_ps(WIFI_PS_NONE);
}
// Called when the library no longer needs low-latency networking.
void on_release_high_performance() override {
esp_wifi_set_ps(WIFI_PS_MIN_MODEM);
}
// Called when a server begins a pairing exchange, once per attempt and whatever
// the method (Pairing PSK, dynamic pairing code, or static pairing code).
// server_id is the base64url public key of the server initiating pairing. The pairing
// callbacks' text views are valid for the duration of the callback.
void on_pairing_attempt(std::string_view server_id) override {
printf("Pairing started with server %.*s\n", static_cast<int>(server_id.size()),
server_id.data());
}
// Called when pairing completes successfully and the long-term record is stored.
// Subsequent connections from this server are paired sessions (is_paired()).
void on_paired(std::string_view server_id) override {
printf("Pairing succeeded with server %.*s\n", static_cast<int>(server_id.size()),
server_id.data());
}
// Called when a pairing exchange is aborted. Most server-sent abort reasons leave the
// connection open so the server can retry or resume normal operation; CONCURRENT_ATTEMPT
// and protocol errors (reported as UNKNOWN) close it.
void on_pairing_aborted(std::string_view server_id, SendspinPairAbortReason reason) override {
printf("Pairing failed with server %.*s\n", static_cast<int>(server_id.size()),
server_id.data());
}
// Dynamic pairing code: emit/withdraw the code the device derived. Only invoked when
// SendspinClientConfig::pairing_code_out_channels and pairing_code_formats are both
// non-empty. `format` says what `code` is: six decimal digits, or a pairing token to
// render as a QR code.
void on_pairing_code(std::string_view code, SendspinPairingCodeFormat format) override {
show_pairing_code_on_display(code, format);
}
void on_clear_pairing_code() override {
clear_pairing_code_from_display();
}
// Prompt/dismiss the operator pairing-window gesture for a gesture-gated attempt (every
// static pairing code attempt, and a dynamic one held back by the round limit). Confirm the
// gesture by calling client.confirm_pairing_window() (thread-safe) once the operator
// performs it; calling it with no attempt waiting opens a standing 5-minute pairing window
// that admits the next attempt without a further gesture.
// client.cancel_pairing_window() closes an open window again.
void on_open_pairing_window() override {
prompt_pairing_button_press();
}
void on_close_pairing_window() override {
dismiss_pairing_prompt();
}
};Listeners and providers are set as raw pointers. They must stay alive for as long as the client can call them: until stop() returns, or until the client is destroyed if stop() is never called. The destructor's only listener call is on_release_high_performance() for a hold still outstanding (see Stopping and Restarting).
MyPlayerListener player_listener;
MyMetadataListener metadata_listener;
MyControllerListener controller_listener;
MyClientListener client_listener;
MyNetworkProvider network_provider;
MyPersistenceProvider persistence_provider;
player.set_listener(&player_listener);
metadata.set_listener(&metadata_listener);
controller.set_listener(&controller_listener);
client.set_listener(&client_listener);
client.set_network_provider(&network_provider); // Required
client.set_persistence_provider(&persistence_provider); // OptionalSet every listener and provider before start() and leave it in place while the client runs: the library calls them without checking whether they changed, so replacing or clearing one (set_listener(nullptr)) while the client runs is not supported, including from inside a callback.
// Start the role threads and the protocol task, and arm the WebSocket server (it listens before
// start() returns when the network provider already reports ready, otherwise once loop() finds it
// ready; with accept_inbound_connections off, no server starts).
// Task priorities and PSRAM settings are taken from SendspinClientConfig.
if (!client.start()) {
// Handle failure
return 1;
}
// Optionally initiate a client-side connection to a known server URL.
// Without this, the client waits for incoming server connections. With
// accept_inbound_connections off, this is the only way a server reaches it. The URL is taken by
// value: pass a temporary, or std::move() a string you no longer need, to avoid a copy.
client.connect_to("ws://192.168.1.10:8928/sendspin");
// Main loop: call loop() periodically to deliver callbacks. Connection work runs on the library's
// protocol task and does not wait for it.
while (running) {
client.loop();
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
// Clean shutdown: goodbye every peer, tear everything down, deliver the clear callbacks.
client.stop();stop() is synchronous: when it returns the client is fully stopped. It sends a client/goodbye (reason shutdown) to every peer and closes each connection right behind its goodbye (a peer whose WebSocket upgrade completes after stop() has begun is closed without one). It does not wait for a queued goodbye to be written: the host transports and the ESP outbound connection send synchronously, and the ESP server writes each queued goodbye before the close queued behind it. It then closes the server and every connection still open, joins the role threads, resets every role, and delivers the roles' clear callbacks (on_stream_end(), on_image_clear(), on_visualizer_stream_end(), on_metadata_clear(), on_controller_state_clear(), on_color_clear(), on_streaming_stopped()) before returning. An open source stream is ended with client-stream/end ahead of its connection's goodbye, except inside a re-handshake's quiet window (connection.md "Re-handshake"): there the end waits for a server/activate a stopping client never receives, so the goodbye alone ends the stream. It is a no-op on a stopped client. is_started() reports the state, and while stopped loop() only frees an outbound attempt stop() left connecting (below).
Restarting is start() again; start, stop, and start again can be repeated indefinitely, and a restarted client begins with no connection, no group state, and no role state from before the stop.
stop() may block, but the wait is bounded. It includes:
- The transports' own close. The host server joins every accepted connection thread; a WebSocket peer completes its close handshake within about 300 ms, but a raw socket that connected and never completed the upgrade holds the join for the full 3 s handshake timeout. The ESP server waits for the httpd task to exit, which polls at 100 ms and first finishes any queued send, which can take up to httpd's send timeout for a peer that has stopped reading.
- An open outbound
connect_to()connection's transport stop, which is synchronous (esp_websocket_client_stop()/ix::WebSocket::stop()): on ESP up to the websocket task's one-second read poll, and usually at once after the goodbye, since the peer closes on it. - A listener callback already running on a role thread: the join cannot interrupt it.
on_audio_write()is bounded by itstimeout_ms;on_image_decode()has no bound.
stop() does not wait for an outbound connect_to() attempt whose WebSocket upgrade has not completed, whether it is still pending or was released earlier by disconnect() or a replacing connect_to(). Stopping one would last up to the transport's connect and handshake timeout: 30 s on host (SendspinClientConnection::HANDSHAKE_TIMEOUT_SECS, the nursery's establish window), and on ESP esp_websocket_client's network_timeout_ms (10 s, SendspinClientConnection::NETWORK_TIMEOUT_MS) for each of the connect's three steps (TCP connect, upgrade request, its response), plus the DNS lookup before them, which lwIP's resolver bounds at 7 s per configured DNS server (3 servers by default, so about 21 s). Such an attempt has no peer to goodbye, so stop() closes it without blocking and leaves it to end on its own, the same way a replacing connect_to() does. It is freed once its transport has finished: by loop() while the client stays stopped, once the attempt fails or opens (one that opens is stopped there, on ESP up to the websocket task's one-second read poll), or by the protocol task of the next start(), which also drops it once its connect bound has passed (on ESP still waiting out the rest of a stalled DNS lookup there, off the main loop). This keeps stop() safe to call from a shutdown hook on a watchdog-supervised task. The waits left are a reap the protocol task had already begun when stop() was called (an earlier attempt dropped at its connect bound), which finishes first, and, after a restart while five or more earlier attempts are all still connecting, the oldest of them, which stop() evicts to make room. Destroying the client is the exception: the destructor joins such an attempt's transport, up to the bounds above.
A pairing attempt in flight is cut short the same way: on_clear_pairing_code() and on_close_pairing_window() fire from inside stop() for a prompt that was still showing, and every provider write still owed (a long-term record a server/pair-finalize had just committed, for example) is performed before stop() returns. The identity and record store survive the stop, so a restarted client keeps its client_id, its pairing token, and every record.
Listener callbacks fire from inside stop(), after every role and the group state have been reset, so a callback that reads the client through its getters sees the stopped state; one that calls connect_to() or disconnect() is ignored. is_started() reads false throughout and is safe to call from any thread. Call start() and stop() only from the main loop thread, and never from a callback (see Thread Safety Summary).
on_request_high_performance() and on_release_high_performance() fire from the main loop with no internal lock held, and their bodies should only toggle the platform networking mode rather than calling back into the client or a role. A time burst waits for the request to have been delivered before it sends its first time message, so a main loop that stalls delays the next clock measurement rather than measuring it in power-save mode; a release is never delayed that way.
Destroying a running client performs the transport half of stop() (goodbye, close, join) and dispatches no role teardown or clear callback; the only listener call is on_release_high_performance(), once for each hold the main loop granted and has not released. A request the main loop never delivered is dropped unheard, so the listener never sees a release without its request. Role-thread callbacks (on_audio_write(), on_image_decode(), visualizer deliveries) can still run until the destructor joins their role, so listeners must outlive the client as described in Step 5. Call stop() first when the clear callbacks matter.
All connections are encrypted with Noise KKpsk2 (X25519 + ChaChaPoly). This
requires no application-level configuration beyond providing a SendspinPersistenceProvider
(so the static keypair survives reboots). Encryption is mandatory; there is no cleartext
fallback.
The library derives client_id from the static X25519 keypair: base64url(public_key)
(43 characters, URL-safe, no padding). This string identifies the device to servers. It is
fixed for the lifetime of the keypair.
Without a persistence provider the keypair is regenerated on every boot. Pairing records and server preferences will not survive reboots in that case.
client.start();
printf("client_id: %s\n", client.client_id().c_str());Pairing creates a long-term record for a specific server. After pairing, connections from that
server resolve via the long-term PSK and are paired sessions (is_paired()).
Pairing is server-initiated. The server includes pairing in the activities of its
server/activate message; the library handles the exchange automatically. The application
observes pairing via SendspinClientListener callbacks:
-
on_pairing_attempt(server_id)-- the exchange has begun. -
on_paired(server_id)-- the record is stored; the server will re-handshake immediately on the new long-term PSK. -
on_pairing_aborted(server_id, reason)-- the exchange failed. SeeSendspinPairAbortReasonbelow for the possible reasons.Whether the connection survives depends on the reason. Most reasons the server sends in a
pair/abortleave it open, so the server can re-activate pairing or resume normal operation on it.CONCURRENT_ATTEMPTcloses it, as do protocol errors (a malformed pairing frame or a bad CPace share), which are reported asUNKNOWNand close the transport without sending aclient/goodbye.A persistence provider that rejects the durable write does not fail the pairing:
on_pairedfires, the record is authoritative for the rest of this boot, and a warning says it will not survive a reboot. Surface that failure from the provider itself if the application needs to act on it. The record is dropped outright, with neither callback firing, only when the store is at capacity with nothing evictable, which the connection budget rules out (MAX_OPEN_CONNECTIONSis below the records floor).Either way, do not treat this callback as a disconnect notification; poll
is_connected()if the application needs to track that.
A server can also end an attempt with a server/activate in place of the next pairing message
(pairing.md "Entering and leaving pairing"). Neither on_paired nor
on_pairing_aborted fires then, but on_clear_pairing_code / on_close_pairing_window still
withdraw any prompt the attempt showed; a pairing window the operator opened stays open.
pairing_psk is the pairing method every client must implement, so it is always offered and a
Pairing PSK always backs it. Unless the application supplies one, the library generates a random
Pairing PSK on first boot and persists it as a persistence_keys::PAIRING_PSK blob. A stored blob
holding an all-zero key or the Sentinel PSK is ignored and replaced the same way. Nothing is
required of the application to enable the method.
To pair, the server must learn that PSK out of band. Surface it as a pairing token -- one
"SP:"-prefixed string carrying the client_id and the PSK together, for the operator to
paste (or scan) into the server:
// std::array<char, SendspinClient::PAIRING_TOKEN_LENGTH>, e.g. "SP:0AAAQ..." with no
// terminating NUL; nullopt before start()
auto token = client.client_pairing_token();
if (token) printf("%.*s\n", static_cast<int>(token->size()), token->data());The token is stable for the lifetime of the PSK, so it can be printed at startup, shown in a UI, or rendered as a QR code.
To ship a device with a factory-provisioned Pairing PSK instead (for example one read from a
factory partition, with its token printed on a label), set it in the config. pairing.md
"Pairing PSK Flow" requires it to be drawn from a CSPRNG per device, never shared across devices:
std::array<uint8_t, 32> factory_psk = read_factory_psk(); // the device's own 32 random bytes
config.pairing_psk = SendspinPsk(factory_psk);A configured Pairing PSK outranks a stored one and is never written to the persistence provider;
the library derives its psk_id. An all-zero key or the published Sentinel PSK is rejected:
start() logs an error and returns false, so a factory partition that was never written cannot
ship a key every peer knows. SendspinPsk wipes its bytes when destroyed, and so does every copy
the client makes; a source buffer such as factory_psk above is the application's to wipe.
client.pairing_token_for(factory_psk) builds the token for any 32-byte PSK rather than the
one in use, which is what a provisioning tool needs to print the token for a key it provisions.
It returns nullopt before start(), since the token also carries the client's identity.
After pairing completes, on_paired fires and the long-term record is stored
by the library in the first free record slot. Subsequent boots load that same slot; no further
provisioning is needed.
The library also supports the two pairing-code methods, gated by
SendspinClientConfig::pairing_code_out_channels / pairing_code_formats /
static_pairing_code, and driven by the SendspinClientListener::on_pairing_code /
on_clear_pairing_code / on_open_pairing_window / on_close_pairing_window callbacks
documented in Step 3 above. A method is advertised only when the platform can carry it: a client
that names no out-channel and no format never offers dynamic_pairing_code, and the server is
then limited to the remaining methods.
A client offers at most one pairing-code method, and one with an out-channel offers the dynamic
code, so a device that means to offer static_pairing_code leaves pairing_code_out_channels
empty.
A dynamic pairing code is derived per attempt from the Noise handshake hash and both sides'
nonces, and emitted through on_pairing_code as either six decimal digits or a pairing
token to render as a QR code, whichever format the server selected from those advertised. If the
operator types a code the device did not emit, the client asks for another round
(client/pair-retry) and keeps the same code on screen. After 20 rounds without a successful
verification the attempt ends and further attempts are held back until an operator gesture.
A static pairing code is the fixed 8-digit value the device shipped with, set in
SendspinClientConfig::static_pairing_code and drawn from a CSPRNG per device (pairing.md "Static
Pairing Code Flow"). A value that is not exactly 8 decimal digits makes start() log an error and
return false. Every attempt is gesture-gated: the client answers the pairing activation with
client/pair-pending and withholds client/pair-init until a pairing window is open. The window
opens on the operator gesture (confirm_pairing_window()) and lives for 5 minutes. It keeps
admitting attempts on the connection that carried its first, and closes on a completed pairing (the
server's server/pair-finalize ack, the point at which the record is stored), on the fifth attempt
whose verification failed, when that connection drops, on cancel_pairing_window(), or on expiry. A
gesture performed before the activation arrives leaves the window standing open, so the next attempt
within its lifetime proceeds without a prompt.
The gesture is also the deliberate operator action pairing.md "Rounds" requires to clear a
standing dynamic round limit. A device that offers either pairing-code method should therefore
implement the gesture: prompt on on_open_pairing_window and call confirm_pairing_window() once
the operator performs it. Without that a gated attempt waits at client/pair-pending until the
server's own timeout cancels it.
SendspinClientConfig::pairing_psk_locations and static_pairing_code_locations tell a server
where the operator can find each secret ("device", "leaflet", "operator"), and ride out as
the locations hint on the matching client/hello pair-method descriptor. Only the application
knows where its secrets were published, so an empty value omits the hint rather than guessing:
a code on the device label is {"device"}, a pairing token in the box is {"leaflet"}, and a
secret an operator provisioned out of band is {"operator"}.
The secrets themselves are pairing_psk (or the generated Pairing PSK) and
static_pairing_code in the same config, so an application that sets one sets its hint beside
it.
is_paired() reports whether the active connection is a paired session, which the PSK that
resolved during the Noise handshake decides:
| PSK used | is_paired() |
Meaning |
|---|---|---|
| Long-term record | true |
Server holds a record minted for it during pairing |
| Sentinel or Pairing PSK | false |
Unpaired access |
Like is_connected(), it can be called from any thread and reflects the protocol task's last
tick, and it reads false while no connection is admitted. An in-band re-handshake can change
it on the same connection: pairing an already-connected server turns it true once the
post-pairing rekey completes. Read it before is_connected(): between a re-handshake and the
server/activate that follows it, it already describes the new session while is_connected()
reads false, and a true is_connected() read after it means that session is activated.
Paired servers (long-term record) may declare playback and activate roles at any time. Unpaired
servers, those connecting with the Pairing PSK or the Sentinel PSK, are always admitted idle
(activities []) or declaring pairing, but may declare playback or activate roles only while
unpaired access is enabled. pairing.md "Unpaired Access" makes the default the
manufacturer's choice and a change a local action on the device, so the application owns the
setting. It is off until set_unpaired_access_enabled() turns it on:
client.set_unpaired_access_enabled(true);
bool on = client.is_unpaired_access_enabled();The call may be made from any thread and works at any time, before the first start() and while
stopped included; the next start() advertises and admits against the value it left.
The library never persists the setting. An application that keeps it across reboots stores it
and restores it by calling set_unpaired_access_enabled() before start(), so the first
client/hello and admission already use it. If that stored value is lost, restore off rather
than the out-of-box default unless the application's own record shows the device has never been
set up, or a damaged store reopens unauthenticated access an operator turned off.
A call on a running client applies the new value to the live connections as pairing.md
"Unpaired Access" describes:
- Turning it off closes every connection that only unpaired access was admitting (an unpaired
server with playback or active roles) with
client/goodbyereasonpairing_required. Paired connections, and unpaired ones with neither playback nor active roles, stay open. - Turning it on closes each unpaired connection a server opened that is not declaring pairing
with reason
restart, so the server reconnects and reads the new value in theclient/hello. Paired connections andconnect_to()connections stay open; the latter keep advertising the old value until they are reopened. A connection on the Pairing PSK still awaiting its firstserver/activatealso stays open: it is most likely about to declare pairing, and a restart would cost that pairing attempt. If it activates idle instead, it keeps theclient/helloit already read until it reconnects.
Connections admitted with the Sentinel PSK are unpaired sessions (is_paired() reads false).
Disabling unpaired access after the device is paired is the typical production configuration.
If you added the controller role, use it to send playback commands. send_command takes a ClientCommandControllerObject, built with designated initializers - set only the field the command uses:
controller.send_command({.command = SendspinControllerCommand::PLAY});
controller.send_command({.command = SendspinControllerCommand::PAUSE});
controller.send_command({.command = SendspinControllerCommand::NEXT});
controller.send_command({.command = SendspinControllerCommand::PREVIOUS});
controller.send_command({.command = SendspinControllerCommand::STOP});
controller.send_command({.command = SendspinControllerCommand::SHUFFLE});
controller.send_command({.command = SendspinControllerCommand::UNSHUFFLE});
controller.send_command({.command = SendspinControllerCommand::REPEAT_OFF});
controller.send_command({.command = SendspinControllerCommand::REPEAT_ONE});
controller.send_command({.command = SendspinControllerCommand::REPEAT_ALL});
// Commands that carry a parameter set the matching field:
controller.send_command({.command = SendspinControllerCommand::VOLUME, .volume = 75});
controller.send_command({.command = SendspinControllerCommand::MUTE, .muted = true});
controller.send_command({.command = SendspinControllerCommand::MUTE, .muted = false});
// Seek to an absolute position (0 to the controller state's seek_max_ms):
controller.send_command({.command = SendspinControllerCommand::SEEK, .position_ms = 30000});
// Seek by a signed offset from the current position (negative seeks backward):
controller.send_command({.command = SendspinControllerCommand::SEEK_RELATIVE, .offset_ms = -10000});Fields that do not match the command are ignored when the message is serialized. The client drops, with a warning, a command missing from the latest controller state's supported_commands, and one without the field it requires (volume in 0-100, muted, position_ms, offset_ms); gate your UI on supported_commands so such calls are not made. The server clamps seeks to the seekable range.
A command is sent only to the admitted connection that owns the controller role, and only while the server has controller@v1 among that connection's active roles. Calls made before the first server/activate, or after one that removes the role, are dropped rather than queued. send_command() checks the command against supported_commands and its parameter on the calling thread, then queues the command itself to the protocol task, which formats the client/command, applies the gate and sends it. A command validated against an owner that was replaced before the protocol task handled it is dropped there, since the new owner never offered it. It returns false for a command it drops itself (not in supported_commands, or a missing parameter) and when the request never reached the task: the client is not running, or the request queue is full. The queue holds a small fixed burst of controller commands (eight; connect_to(), disconnect(), leave(), the pairing-window gestures and unpaired-access changes never take a slot and are never refused), so a control that fires faster than the protocol task drains it, such as a rotary encoder sending a volume step per detent, sees send_command() return false and should coalesce and retry. A false for an unsupported command or a missing parameter is not one a retry can fix: retry only on a full queue, and gate the UI on supported_commands for the rest. true means queued, not sent.
In addition to the references returned by add_*(), you can access roles at any time through the client's accessor methods. These return nullptr if the role was not added.
if (auto* p = client.player()) {
p->update_volume(75);
}
if (auto* c = client.controller()) {
c->send_command({.command = SendspinControllerCommand::NEXT});
}
if (auto* m = client.metadata()) {
uint32_t progress = m->get_track_progress_ms();
}
if (auto* a = client.artwork()) { /* ... */ }
if (auto* v = client.visualizer()) { /* ... */ }
if (auto* col = client.color()) { /* ... */ }
if (auto* s = client.source()) { /* ... */ }Use these accessors when the role reference from add_*() is out of scope.
Note: Role registration methods (
add_player(), etc.), accessor methods (player(), etc.), and their backing members are conditionally compiled based onSENDSPIN_ENABLE_*flags. When a role is disabled at build time, callingadd_player()orclient.player()is a compile error, not a runtime nullptr. See Compile-Time Role Selection below.
Report local state changes back to the server:
player.update_volume(75);
player.update_muted(false);
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);A device can be taken over by something other than Sendspin: a local source, another protocol, an HDMI input. How it tells the server depends on whether Sendspin may take it back (messaging.md "External Source Handling").
A device that plays a local source it can be interrupted out of stays available and leaves its group:
client.leave(); // Sends client/leaveThe server treats this as it treats a client becoming unavailable: the client ends up alone in a stopped group and rejoins only when an operator switches it back. Availability is unchanged, so the server may still take the client over for new playback.
Leaving is only meaningful while the group is playing; a client in a stopped group keeps its
grouping by staying. The call needs an admitted connection that has received its first
server/activate, and is ignored (with a log) otherwise. It may be called from any thread: the
request is posted to the protocol task, which never refuses it, and calls before the task takes
it send one client/leave.
A device that will not yield to Sendspin while the other activity runs reports itself unavailable, and available again as soon as it would yield:
client.set_available(false); // client/state with available: false
client.set_available(true); // Sendspin may take the device over again
bool available = client.is_available();The server moves an unavailable client into a stopped group of its own and does not take it over
until it is available again. Until the server ends the stream, the player discards the audio
that still arrives. An open source stream closes with client-stream/end before the
client/state reporting available: false, and a start received while unavailable is ignored.
Availability is kept across disconnects and stop()/start(), and only a change publishes a
client/state. Call it from the main loop thread.
The client and roles expose query methods for polling state in your main loop or UI update cycle:
// Client state
bool paired = client.is_paired(); // Active connection is a paired session (read first)
bool connected = client.is_connected(); // Active connection with completed handshake
bool synced = client.is_time_synced(); // Time filter has received at least one measurement
GroupState group = client.group_state(); // Group id, name, playback state (all optional);
// its views hold until control returns to loop(), or stop()
auto server = client.server_information(); // Active server's id and name as of the last loop(),
// nullopt with none; views held like group_state()'s;
// can trail is_connected() by one loop(), so read it
// each loop rather than on its rising edge
// Player state
uint8_t vol = player.get_volume();
bool muted = player.get_muted();
uint16_t delay = player.get_output_delay_ms();
auto& stream = player.get_current_stream_params();
// Controller state
auto& ctrl = controller.get_controller_state(); // volume, muted, repeat, shuffle, supported_commands, seek_max_ms
// Metadata
uint32_t progress = metadata.get_track_progress_ms(); // Interpolated
uint32_t duration = metadata.get_track_duration_ms();
// Timestamp conversion
int64_t client_ts = client.get_client_time(server_timestamp);The view forms hand the metadata, group, pairing and server text over as std::string_view into storage the library keeps, and the pairing token as a std::array; the deprecated forms copy it into std::string. The std::string forms are deprecated in v0.10.0 and removed in v0.11.0. Using a deprecated type or calling a deprecated getter draws a -Wdeprecated-declarations warning, but overriding a deprecated callback does not: the metadata and group callbacks warn only because their parameter types are deprecated, and the four pairing callbacks (on_pairing_started(), on_pairing_succeeded(), on_pairing_failed(), on_display_pairing_code()) take none, so search for those names. Mark every override override, so that a callback removed in v0.11.0 fails to compile rather than silently going uncalled.
| Deprecated | Replacement |
|---|---|
ServerMetadataStateObject |
TrackMetadata |
MetadataRoleListener::on_metadata() |
MetadataRoleListener::on_track_metadata() |
GroupUpdateObject |
GroupState |
SendspinClientListener::on_group_update() |
SendspinClientListener::on_group_state() |
SendspinClient::get_group_state() |
SendspinClient::group_state(), which returns by value |
SendspinClientListener::on_pairing_started() |
SendspinClientListener::on_pairing_attempt() |
SendspinClientListener::on_pairing_succeeded() |
SendspinClientListener::on_paired() |
SendspinClientListener::on_pairing_failed() |
SendspinClientListener::on_pairing_aborted() |
SendspinClientListener::on_display_pairing_code() |
SendspinClientListener::on_pairing_code() |
SendspinClient::pairing_token() |
SendspinClient::client_pairing_token(), a std::array<char, PAIRING_TOKEN_LENGTH> |
SendspinClient::format_pairing_token() |
SendspinClient::pairing_token_for(), likewise |
ServerInformationObject |
ServerInformation |
SendspinClient::get_server_information() |
SendspinClient::server_information(), main loop only |
The fields and arguments keep their meaning; only the text type changes. A view handed to a callback is valid for the duration of the callback, and the views group_state() and server_information() return until control next returns to loop() (from inside a callback: until the callback returns) or until stop(): copy what is needed beyond that. A view, like a pairing token's array, is not guaranteed to be NUL-terminated: print it with %.*s (its size, then its data) or copy it into a std::string before passing it to an API that expects a C string. server_information() reads a copy loop() refreshes as the server changes, so it is main loop only and can read the previous server, or nullopt, for one loop() after is_connected() turns true: read it on each loop() rather than only when is_connected() rises; get_server_information() reads from any thread, and code off the main loop has no replacement and keeps calling it until the removal. Until the removal the library calls the new callback and then the deprecated one with the same content, so a listener overrides one of the two; one that overrides both gets both, the new one first.
The library runs its connection work on its own protocol task (SsProto): every handshake,
admission, pairing exchange, time sync, watchdog and send. loop() only delivers what that task
and the role threads produced, so a slow main loop delays callbacks but not connections.
Most listener callbacks fire on the main loop thread (the thread calling client.loop()). The exceptions are:
| Callback | Thread |
|---|---|
PlayerRoleListener::on_audio_write() |
Sync task background thread |
ArtworkRoleListener::on_image_decode() |
Dedicated artwork decode thread |
ArtworkRoleListener::on_image_display() |
Main loop thread |
VisualizerRoleListener data callbacks (on_loudness(), on_beat(), on_f_peak(), on_spectrum(), on_peak()) |
Dedicated visualizer drain thread |
SendspinClientConfig::message_trace (built with SENDSPIN_ENABLE_MESSAGE_TRACE) |
Protocol task |
| All other listener methods | Main loop thread |
Never call loop(), start() or stop() from a listener, role or provider callback. A callback on the main loop already runs inside one of the client's own calls (loop(), start(), stop() or its destructor), none of which supports being re-entered; a callback the table lists on another thread is off the main loop, where these calls never run. To stop or restart the client in response to a callback, record the request in the callback and act on it from the main loop between two loop() calls.
PlayerRole::notify_audio_played() is thread-safe and is designed to be called from an audio output callback thread.
ControllerRole::send_command() is callable from any thread.
SourceRole::write_audio() is for exactly one capture thread; the library does not serialize concurrent writers. SourceRole::set_signal() and is_streaming() are main loop only.
ArtworkRole::frame_done() must be called from the main loop thread (typically from inside on_image_display()/on_image_clear() or when a cross-fade animation completes).
SendspinPersistenceProvider calls are on the main loop thread for every key (see the
SendspinPersistenceProvider section above). SendspinNetworkProvider::is_network_ready() is
also called on the main loop only, from start() and loop(), and must be cheap and
non-blocking.
Callable from any thread: connect_to(), disconnect(), leave(),
confirm_pairing_window(), cancel_pairing_window(), set_unpaired_access_enabled() and the
getters is_started(), is_connected(), is_time_synced(), get_client_time(),
get_server_information() (deprecated), is_paired() and is_unpaired_access_enabled(). The
requests take effect on the protocol task's next tick; the getters read what that task last
published. Controller commands are queued, so a burst of them can fill the
queue (see Sending Commands); connect_to(), disconnect(), leave(),
the pairing-window gestures and set_unpaired_access_enabled() are posted instead, never
refused, and each holds only its latest call (the latest URL, the latest disconnect reason, the
later of a confirm and a cancel). A connect_to() and a disconnect() resolve by call order: a
connect_to() after a disconnect() opens the new attempt once the goodbyes are sent, and a
disconnect() after a connect_to() the task has not acted on cancels it. Posted requests are
applied ahead of the sends queued in the same window, so a send queued before a disconnect()
is dropped rather than sent after the goodbye, while a server connection that arrived before it
still goes through admission: a disconnect addresses the current connections, not a newcomer.
Releasing an outbound attempt that is still connecting (a
disconnect(), or a connect_to() that replaces it) does not wait for its transport either:
the attempt is closed without blocking and freed once its transport has finished, at the latest
once its connect bound has passed (30 s on host; on ESP-IDF three connect steps of 10 s each,
plus the DNS lookup, which lwIP's resolver bounds at 7 s per configured DNS server), so the
requests behind it are not held up, except that on ESP-IDF an attempt dropped at that bound
while its DNS lookup is still running holds the protocol task until the lookup gives up. Everything else (start(), stop(), loop(), the listener, provider and role setters, and role registration) belongs
to the main loop.
The pairing exchange (CPace and SHA-512) runs on the protocol task with the Noise handshakes, so
protocol_task_stack_size has to carry the deepest crypto frame.
A minimal integration that receives and discards audio:
#include "sendspin/client.h"
#include "sendspin/player_role.h"
#include <chrono>
#include <thread>
using namespace sendspin;
struct MinimalPlayer : PlayerRoleListener {
size_t on_audio_write(uint8_t* data, size_t length, uint32_t timeout_ms) override {
return length; // Discard audio
}
};
struct AlwaysReady : SendspinNetworkProvider {
bool is_network_ready() override { return true; }
};
int main() {
SendspinClientConfig config;
config.name = "Minimal Client";
SendspinClient client(std::move(config));
PlayerRoleConfig player_config;
player_config.audio_formats = {{SendspinCodecFormat::PCM, 2, 44100, 16}};
auto& player = client.add_player(std::move(player_config));
MinimalPlayer player_listener;
AlwaysReady network;
player.set_listener(&player_listener);
client.set_network_provider(&network);
client.start();
while (true) {
client.loop();
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
}By default all roles are enabled. You can disable roles at build time to exclude their code (and dependencies like audio decoders) from the binary. This is useful on constrained targets where flash space matters.
Pass -D options to cmake:
# Disable the player role (excludes decoder and sync task)
cmake -B build -DSENDSPIN_ENABLE_PLAYER=OFF
# Disable all optional roles, keep only the player
cmake -B build -DSENDSPIN_ENABLE_CONTROLLER=OFF \
-DSENDSPIN_ENABLE_METADATA=OFF \
-DSENDSPIN_ENABLE_ARTWORK=OFF \
-DSENDSPIN_ENABLE_VISUALIZER=OFF \
-DSENDSPIN_ENABLE_COLOR=OFF \
-DSENDSPIN_ENABLE_SOURCE=OFFAvailable options (all ON by default):
| Option | Controls |
|---|---|
SENDSPIN_ENABLE_PLAYER |
Player role, audio decoders (micro-flac, and micro-opus with SENDSPIN_ENABLE_OPUS), sync task |
SENDSPIN_ENABLE_OPUS |
Opus (micro-opus): the player's decoder and the source's encoder; no effect when both roles are OFF |
SENDSPIN_ENABLE_CONTROLLER |
Controller role |
SENDSPIN_ENABLE_METADATA |
Metadata role |
SENDSPIN_ENABLE_ARTWORK |
Artwork role |
SENDSPIN_ENABLE_VISUALIZER |
Visualizer role |
SENDSPIN_ENABLE_COLOR |
Color role |
SENDSPIN_ENABLE_SOURCE |
Source role (audio capture streamed to the server), audio encoders (micro-flac, and micro-opus with SENDSPIN_ENABLE_OPUS), source task |
When SENDSPIN_ENABLE_PLAYER is OFF, micro-flac and micro-opus are fetched only for the source role, whose FLAC and Opus encoders they provide. SENDSPIN_ENABLE_OPUS=OFF drops micro-opus alone, for products that cannot ship Opus (see the patent note in the player and source role specs); the player then refuses OPUS entries in audio_formats, and the source refuses an OPUS codec in its config.
SENDSPIN_ENABLE_MESSAGE_TRACE (OFF by default) adds SendspinClientConfig::message_trace, a callback that receives every protocol message's plaintext (inbound after decryption, outbound before encryption, plus the cleartext text frames), for conformance tooling and interop debugging. The ESP-IDF build has no switch for it. The callback runs on the protocol task and must not block. The pairing secrets a client sends read "<redacted>"; SendspinTracedMessage in config.h documents the fields.
config.message_trace = [](const SendspinTracedMessage& message) {
// message.plaintext is valid only during this call: copy what you keep
};Role flags are exposed via Kconfig under Component config → sendspin-cpp:
CONFIG_SENDSPIN_ENABLE_PLAYER=y
CONFIG_SENDSPIN_ENABLE_OPUS=y
CONFIG_SENDSPIN_ENABLE_CONTROLLER=y
CONFIG_SENDSPIN_ENABLE_METADATA=y
CONFIG_SENDSPIN_ENABLE_ARTWORK=y
CONFIG_SENDSPIN_ENABLE_VISUALIZER=y
CONFIG_SENDSPIN_ENABLE_COLOR=y
CONFIG_SENDSPIN_ENABLE_SOURCE=y
The component also selects esp_websocket_client's ESP_WS_CLIENT_SEPARATE_TX_LOCK, so sends on an outbound connection (connect_to()) take their own lock rather than the one the client task holds while the receive handler waits for inbound ring space. With the source role and Opus enabled, micro-opus must stay in its thread-safe pseudostack allocation mode (the default); the build fails otherwise. The source's FLAC encoder never turns on micro-flac's LPC or Rice partitioning, so a build that needs neither elsewhere can turn off MICRO_FLAC_ENCODER_ENABLE_LPC (about 19 KB of flash) and MICRO_FLAC_ENCODER_ENABLE_RICE_PARTITIONS in Kconfig.
When a role is disabled, its add_*() method, accessor method, and backing member are removed from client.h via #ifdef guards. Attempting to call client.add_player() when SENDSPIN_ENABLE_PLAYER is OFF produces a compile error. The corresponding role header can still be included (it defines protocol types and the listener interface), but the role class cannot be instantiated.
Main client configuration passed to the SendspinClient constructor.
client_id is not a field in SendspinClientConfig. It is derived from the static
X25519 keypair and read back via client.client_id() after start().
| Field | Type | Default | Description |
|---|---|---|---|
name |
std::string |
(none) | Friendly display name shown in the Sendspin UI |
product_name |
std::optional<std::string> |
unset | Device product name; sent in client/hello only when set |
manufacturer |
std::optional<std::string> |
unset | Manufacturer name (e.g., "ESPHome"); sent in client/hello only when set |
software_version |
std::optional<std::string> |
unset | Software version string; sent in client/hello only when set |
mac_address |
std::optional<std::string> |
auto-detected | MAC address of the network interface, lowercase colon-separated (e.g., "aa:bb:cc:dd:ee:ff"), sent in client/hello. Left unset, the library auto-detects it. ESP-IDF uses the default network interface (Wi-Fi or Ethernet). Host uses a best-effort from the active routable interface. Set explicitly to override (recommended on multi-homed hosts). |
pairing_code_out_channels |
std::vector<SendspinPairingCodeChannel> |
{} |
Where the device can emit a dynamic pairing code: DISPLAY, SPEAKER. Advertised as the descriptor's out_channels. Empty (or an empty pairing_code_formats) means the device cannot emit one, so dynamic_pairing_code is not advertised. |
pairing_code_formats |
std::vector<SendspinPairingCodeFormat> |
{} |
How the device can render a dynamic pairing code: DIGITS (six decimal digits), QR_CODE (a pairing token to render). Advertised as the descriptor's formats; the server picks one from this list. |
max_pairing_records |
size_t |
12 |
Maximum number of long-term pairing records RecordStore retains, each costing about 123 B of internal RAM reserved at start(). See Record capacity. |
httpd_psram_stack |
bool |
false |
Allocate HTTP server task stack in PSRAM (ESP-IDF only) |
httpd_priority |
unsigned |
5 |
FreeRTOS priority for the HTTP server task (ESP-IDF only) |
httpd_stack_size |
size_t |
4608 |
HTTP server task stack size in bytes (ESP-IDF only). The task runs esp_http_server, receives frames into the inbound ring and runs queued sends. Like every task stack default here, it is derived in tools/stack_usage/README.md. Values below the default are clamped up to it with a warning. Raising it is allowed. |
websocket_priority |
unsigned |
5 |
FreeRTOS priority for the WebSocket client task (ESP-IDF only) |
websocket_stack_size |
size_t |
4608 |
esp_websocket_client task stack size in bytes (ESP-IDF only), the outbound connection's transport task. Derived as for httpd_stack_size. Values below the default are clamped up to it with a warning. Raising it is allowed. |
protocol_task_psram_stack |
bool |
false |
Allocate the protocol task (SsProto) stack in PSRAM (ESP-IDF only). The task never writes flash (the persistence provider runs on the main loop), so its stack may live in PSRAM; a consumer that already exposes httpd_psram_stack (ESPHome does) should pass this through beside it, which moves the largest library task stack out of internal RAM on a device with PSRAM. |
protocol_task_priority |
unsigned |
5 |
FreeRTOS priority for the protocol task (ESP-IDF only). Defaults to the httpd task's priority, below the player's sync task. |
protocol_task_stack_size |
size_t |
7168 |
Protocol task stack size in bytes (ESP-IDF only). Every Noise handshake (including the in-band re-handshake after pairing, with its X25519 crypto), the pairing exchange (CPace and SHA-512), the JSON parse, the role handlers and every send run on this task. Derived as for httpd_stack_size. Values below the default are clamped up to it with a warning. |
server_port |
uint16_t |
8928 |
WebSocket server port |
accept_inbound_connections |
bool |
true |
Whether to run the WebSocket server that servers connect to. false leaves connect_to() as the only way a server reaches the client, for a client that dials one configured server and must not be reachable by any other; server_port is then unused. |
server_max_connections |
uint8_t |
4 |
Maximum simultaneous WebSocket connections (one established, two unproven, and one spare so a surplus peer can be rejected with a goodbye); at most 7 on ESP, where a larger value is clamped |
httpd_ctrl_port |
uint16_t |
0 |
ESP-IDF httpd control port; 0 uses ESP_HTTPD_DEF_CTRL_PORT + 1 to avoid conflict with the web_server component |
time_burst_size |
uint8_t |
8 |
Number of messages per time sync burst |
time_burst_interval_ms |
int64_t |
10000 |
Milliseconds between time sync bursts |
time_burst_response_timeout_ms |
int64_t |
10000 |
Milliseconds before a burst message times out |
liveness_timeout_ms |
std::optional<int64_t> |
unset (60000 with default burst settings) |
Milliseconds of inbound silence before the established connection is dropped as dead, with a restart goodbye so a server that was only slow reconnects. Unset derives it from the time burst settings, tolerating two consecutive unanswered time messages. An explicit value below time_burst_interval_ms + time_burst_response_timeout_ms drops healthy connections. Set or derived, it is capped at SendspinClientConfig::MAX_LIVENESS_TIMEOUT_MS (30 minutes). 0 disables the check. |
inbound_ring_location |
MemoryLocation |
PREFER_EXTERNAL |
Memory placement for the shared inbound ring every admitted connection receives into (sized from the player's audio_buffer_capacity, the visualizer's buffer_capacity, one image per artwork channel in flight plus the artwork images that arrive behind a held audio chunk, and a baseline every configuration pays, which is also its floor, of two of the longest messages the enabled roles need in one piece: 131,152 bytes with the artwork role or a player whose advertised buffer reaches a Noise frame, 32,880 with neither and no larger visualizer message, two of the player's longest chunks in between; audio is decoded straight out of it) and each connection's fallback buffer, which holds a pre-admission message or a message longer than the ring takes. The sizing budgets the control messages and time replies that arrive while the oldest held audio chunk or visualizer frame, or an artwork part waiting for the decode thread to copy it, is out; an exhausted ring closes the connection (The Inbound Ring). PREFER_EXTERNAL tries SPIRAM first and falls back to internal RAM; PREFER_INTERNAL does the reverse. Use PREFER_INTERNAL on devices with slow PSRAM (e.g., plain ESP32) to avoid stuttering. ESP-IDF only; ignored on host. |
noise_buffer_location |
MemoryLocation |
PREFER_EXTERNAL |
Memory placement for the Noise transport's fragment reassembly buffer and the outbound send buffer every JSON message is serialized into. The reassembly buffer grows with the largest fragmented message received (a player audio chunk) and retains its capacity for the life of the connection, so keeping it in SPIRAM protects internal RAM. Independent of inbound_ring_location (which covers the inbound ring). ESP-IDF only; ignored on host. |
pairing_psk |
std::optional<SendspinPsk> |
unset | A factory-provisioned Pairing PSK (32 bytes). Outranks a stored one and is never persisted; an all-zero key or the Sentinel PSK makes start() fail. Unset loads the stored one or generates and persists one on first boot. See Pairing PSK. |
static_pairing_code |
std::optional<std::string> |
unset | The device's static pairing code, exactly 8 decimal digits. The static_pairing_code method is advertised only when this is set and dynamic_pairing_code is not advertised. An invalid value makes start() fail. |
pairing_psk_locations |
std::vector<std::string> |
{} |
Where the operator can find the pairing token the device shipped with: any of "device", "leaflet", "operator". Advertised as the informational locations hint on the pairing_psk descriptor in client/hello; empty omits the hint, see The locations hint. |
static_pairing_code_locations |
std::vector<std::string> |
{} |
Where the operator can find the static pairing code the device shipped with, same values as above. Advertised on the static_pairing_code descriptor in client/hello; empty omits the hint. |
application_roles |
std::vector<SendspinApplicationRole> |
{} |
Application-specific roles listed in client/hello supported_roles after the library's own, each with an optional support object sent as "<role>_support". A malformed name, a repeated name, a support object that is not one JSON object meeting the rules in Application-Specific Roles, or a client/hello over 16384 bytes makes start() fail. |
json_arena_size |
size_t |
2048 |
Size in bytes of a fixed internal-RAM scratch buffer that backs every JSON document the protocol task works with: the parse of each incoming protocol message and every message it builds, instead of PSRAM. Costs this many bytes of internal RAM permanently but removes PSRAM traffic from the protocol task on every message. The arena holds one document at a time: a parsed message is released before the reply it triggers (the client/state after a server/activate, a pairing reply, a re-handshake's second message) is built. The default covers one steady-state protocol message, including the FLAC stream-start header; a document larger than the budget on its own, such as a large track-metadata message or a client/hello carrying large application support objects, falls back to the platform heap, PSRAM where present (a metadata message arrives once per song, a hello once per connection). That holds on a 32-bit target, where ArduinoJson allocates each variant pool as one 1,024-byte block. Every freed block is wiped, so a document that held key material leaves none behind. Set to 0 to send every document to PSRAM. On host there is no PSRAM distinction, so the arena is just a fixed scratch buffer, and since a variant pool alone is 4 KB there, the default fits no document's pool on host. |
message_trace |
SendspinMessageTraceFn |
empty | Only in a build with SENDSPIN_ENABLE_MESSAGE_TRACE (host only): called with every protocol message's plaintext, on the protocol task. See Message trace (host only). |
Configuration passed to client.add_player().
| Field | Type | Default | Description |
|---|---|---|---|
audio_formats |
std::vector<AudioSupportedFormatObject> |
{} |
Audio formats the player supports, in priority order; advertised to the server during the hello handshake. The server selects one when establishing a stream. Must list at least one FLAC or PCM entry, the codecs every server supports; OPUS may be listed in addition when the build has the Opus decoder (SENDSPIN_ENABLE_OPUS). start() fails and logs otherwise. |
audio_buffer_capacity |
size_t |
1000000 |
Bytes of the shared inbound ring the player may hold as encoded audio (its quota; the ring is sized to include it). Each chunk is charged its stored size, so the client advertises the share that holds encoded frames at the smallest chunk size (2/3 of it) to the server; a server filling that share with frames under 144 bytes overruns the quota, and the excess is dropped with a warning. The advertised value is at most the ring's largest item so that any single chunk the server may send fits (621,312 bytes with the default ring, below the 666,666-byte share). The ring also holds the traffic that arrives while the oldest chunk is held (derive_inbound_ring_bytes() in src/inbound_ring.h), so the default quota yields a 1,242,704-byte ring; a visualizer's frame rate and the artwork channels' images add to it (1,641,776 bytes with one 128 KB artwork channel). Larger buffers absorb more jitter at the cost of memory. While the oldest chunk is held, the control messages and time replies have only that pass-through allowance; a sync task stalled in on_audio_write() or a server far over the advertised buffer can exhaust it, and an exhausted ring closes the connection with "ring pinned behind held items" (The Inbound Ring). |
fixed_delay_us |
int32_t |
0 |
Fixed platform-level delay offset in microseconds (e.g., a known I2S pipeline delay). Applied on top of the user-adjustable output delay. |
initial_output_delay_ms |
uint16_t |
0 |
Initial value for the user-adjustable output delay in milliseconds. Overridden by the persisted value if a SendspinPersistenceProvider is set. |
extra_startup_silence_ms |
uint16_t |
50 |
Extra silence inserted at stream start, after the first playback notification and before the first decoded chunk reaches the sink. Added on top of the initial-sync priming silence to give the decode pipeline more slack to stay ahead of the sink, preventing the initial-playback stutter caused by the decoder briefly falling behind. Larger values trade a longer startup delay for more underflow protection; set to 0 to disable. |
required_lead_time_ms |
std::optional<uint16_t> |
unset (150 with the default startup silence) |
Startup lead in milliseconds reported to the server as required_lead_time_ms, measured from the server's transmission of a stream/start or stream/clear to the playback timestamp of the first chunk that can be played in full. Unset reports what the pipeline itself spends: the sync task's 25 ms priming silence, the configured extra_startup_silence_ms, and a 75 ms allowance for codec init, the first decode and the audio backend. Set it for an output with more startup latency than that allowance; a value below what the pipeline spends is raised to it. The server treats it as a hint and may give less lead. |
min_buffer_ms |
uint16_t |
500 |
Ongoing buffer duration in milliseconds reported to the server as min_buffer_ms: how much audio the player wants held ahead of playback during a stream to absorb network jitter and decode timing variance. Mostly relevant for live streams. The audio it represents must fit audio_buffer_capacity at the highest-bitrate entry in audio_formats. |
psram_stack |
bool |
false |
Allocate sync/decode task stack in PSRAM (ESP-IDF only) |
priority |
unsigned |
6 |
FreeRTOS priority for the sync/decode task (ESP-IDF only). The default value, 6, is one above the default httpd_priority (5). If you customize priorities, keep this above httpd_priority so the HTTP server task cannot starve the decoder during the initial burst of encoded audio that fills the buffer at stream start. |
decode_buffer_location |
MemoryLocation |
PREFER_EXTERNAL |
Memory placement preference for the decode transfer buffer. PREFER_EXTERNAL tries SPIRAM first and falls back to internal RAM; PREFER_INTERNAL does the reverse. ESP-IDF only; ignored on host. |
Each entry in audio_formats is an AudioSupportedFormatObject:
| Field | Type | Description |
|---|---|---|
codec |
SendspinCodecFormat |
Audio codec (FLAC, OPUS, or PCM) |
channels |
uint8_t |
Number of audio channels |
sample_rate |
uint32_t |
Sample rate in Hz |
bit_depth |
uint8_t |
Bits per sample |
Configuration passed to client.add_metadata(), which defaults it.
| Field | Type | Default | Description |
|---|---|---|---|
title, artist, album_artist, album, artwork_url |
bool |
true |
Whether to keep that text field of TrackMetadata. A field set false is dropped as each state is parsed and reaches the listener as nullopt; year, track and progress are always kept. Each kept field costs its cap (255 bytes, 2048 for artwork_url) in the string of the deprecated ServerMetadataStateObject, reserved by add_metadata() through operator new (internal RAM on ESPHome), and in each of the three text blocks that carry states from the protocol task to the main loop, allocated by start(). With every field kept that is 3,068 bytes of string capacity and 9,204 bytes of blocks; with {.artwork_url = false}, 1,020 and 3,060. start() fails, logged, when the blocks cannot be allocated. |
buffer_location |
MemoryLocation |
PREFER_EXTERNAL |
Memory placement preference for the text blocks. PREFER_EXTERNAL tries SPIRAM first and falls back to internal RAM; PREFER_INTERNAL does the reverse. ESP-IDF only; ignored on host. |
Configuration passed to client.add_artwork().
| Field | Type | Default | Description |
|---|---|---|---|
preferred_formats |
std::vector<ImageSlotPreference> |
{} |
Image slot preferences advertised to the server during the hello handshake. Each entry declares an image source, format, and resolution; the slot/channel number is the entry's index in this vector. |
psram_stack |
bool |
false |
Allocate decode thread stack in PSRAM (ESP-IDF only) |
priority |
unsigned |
2 |
FreeRTOS priority for the decode thread (ESP-IDF only) |
Each entry in preferred_formats is an ImageSlotPreference. The slot/channel number is the entry's index in preferred_formats (first entry is slot 0), so entries are declared in slot order:
| Field | Type | Description |
|---|---|---|
source |
SendspinImageSource |
Image source (ALBUM or ARTIST) |
format |
SendspinImageFormat |
Image format (JPEG or PNG) |
width |
uint16_t |
Desired image width in pixels |
height |
uint16_t |
Desired image height in pixels |
require_frame_done |
bool |
Opt-in back-pressure gate (default false). When set, the role delivers at most one un-acked frame or clear at a time for this slot; the consumer must call ArtworkRole::frame_done(slot) to release the gate. See ArtworkRoleListener. |
display_offset_ms |
int32_t |
Shifts the display deadline (default 0). Positive fires on_image_display() earlier (mirroring PlayerRoleConfig::fixed_delay_us), negative delays it; lets a cross-fade straddle the track boundary. |
max_image_bytes |
uint32_t |
Largest encoded image this channel holds, in bytes (default 128 KiB). A larger image is refused: the transfer is followed to its end with its bytes dropped and the channel keeps what it was showing. The role holds one assembly buffer of this size per configured channel from start() to stop(), which an image waits in while a require_frame_done channel's last delivery is un-acked, and the inbound ring reserves one image of this size per channel in flight, sent in parts of at least 4,096 bytes (the reference server sends maximal parts of 65,517 bytes): the quota charges each part its stored size, so an image split into smaller parts can exceed it and is dropped while the decode thread is busy. That quota is one image: a second image for the channel arriving behind a complete one still queued while the decode thread is inside a long on_image_decode() is dropped, and the channel keeps its current image until the server's next one. A third image arriving while on_image_decode() is still inside the first one is dropped the same way, but with no hold window of 30 s or more (artwork alone, or a visualizer holding under 30 s) its refused parts stay pinned behind the queued image, so a stall long enough for time replies and state to arrive behind them can exhaust the ring and close the connection; that takes a callback blocked for about two image cadences or track skips faster than the ring's budget. 0 holds nothing at all. |
Configuration passed to client.add_visualizer().
| Field | Type | Default | Description |
|---|---|---|---|
support |
VisualizerSupportObject |
- | Visualizer capabilities advertised to the server during the hello handshake |
stream |
VisualizerStreamConfig |
- | Stream configuration reported to the server in client/state |
psram_stack |
bool |
false |
Allocate drain thread stack in PSRAM (ESP-IDF only) |
display_offset_ms |
int32_t |
0 |
Fires the data callbacks this far ahead of each frame's display time (negative delays them), for a consumer's own render latency. The callbacks' client_timestamp stays the display time. |
priority |
unsigned |
2 |
FreeRTOS priority for the drain thread (ESP-IDF only) |
VisualizerSupportObject fields:
| Field | Type | Description |
|---|---|---|
buffer_capacity |
size_t |
Bytes of the shared inbound ring the visualizer may hold (its quota; the ring is sized to include it). Per-item overhead means only ~1/7 holds wire data at the smallest frame size; the client advertises that effective capacity to the server. The advertised value is at most the ring's largest item so that any single chunk the server may send fits, which a seventh of the quota always is. Below 70 bytes (the smallest budget that advertises one frame) the role refuses to start. The ring's pass-through budget assumes the server fills this quota at rate_max, the shortest the oldest frame is held; a sparser stream (a few beats a second) holds it longer and pins more traffic behind it than budgeted, which exhausts the ring and closes the connection with "ring pinned behind held items" (The Inbound Ring) |
VisualizerStreamConfig fields:
| Field | Type | Description |
|---|---|---|
types |
std::vector<VisualizerDataType> |
Data stream types to receive (BEAT, LOUDNESS, F_PEAK, SPECTRUM, PEAK); may be empty to request none. A type a stream serves outside them is dropped |
rate_max |
uint16_t |
Maximum periodic frames per second; set to the display refresh rate. Must be positive when types is non-empty, or SendspinClient::start() fails |
spectrum |
std::optional<VisualizerSpectrumConfig> |
Spectrum analysis parameters; required when SPECTRUM is in types, or SendspinClient::start() fails |
VisualizerSpectrumConfig fields:
| Field | Type | Description |
|---|---|---|
n_disp_bins |
uint8_t |
Number of frequency bins to receive; start() reserves this many for on_spectrum(). A stream may serve fewer; one that serves more, or another scale or frequency range, drops its spectrum frames |
scale |
VisualizerSpectrumScale |
Frequency scale (MEL, LOG, or LIN) |
f_min |
uint16_t |
Minimum frequency in Hz |
f_max |
uint16_t |
Maximum frequency in Hz |
Configuration passed to client.add_source(). A value that breaks any rule below logs at ERROR and leaves the role inert, neither advertised nor streaming; values are never clamped.
| Field | Type | Default | Description |
|---|---|---|---|
codec |
SendspinCodecFormat |
PCM |
PCM sends the capture bytes untouched; FLAC encodes each chunk losslessly into one FLAC frame; OPUS encodes each chunk into one CELT-only Opus packet (needs SENDSPIN_ENABLE_OPUS). FLAC costs the micro-flac encoder (about 2 KB) and a chunk of scratch. OPUS costs the encoder state plus a micro-opus scratch arena for the source task (about 120 KB, PSRAM-preferring, allocated before the first Opus chunk is encoded), separate from the one the player's sync task holds. |
sample_rate |
uint32_t |
48000 |
Capture rate in Hz, more than 0. OPUS takes 8000, 12000, 16000, 24000, or 48000. |
channels |
uint8_t |
2 |
Capture channel count, at least 1 (at most 8 for FLAC, 1 or 2 for OPUS) |
bit_depth |
uint8_t |
16 |
16, 24 (3 packed bytes), or 32; OPUS takes 16 |
chunk_duration_ms |
uint32_t |
20 |
Audio per chunk, 5 to 150 ms; one chunk with its header must fit one Noise transport message. FLAC needs at least 16 frames a chunk, and its worst-case frame, a little over the PCM, must fit too. OPUS takes 5, 10, 20, 40, or 60. |
capture_buffer_ms |
uint32_t |
500 |
More than 0 ms; about the longest network stall the stream rides out without a gap; a longer one resumes from live capture. A single write must fit in just under 5/8 of it. |
opus_bitrate |
uint32_t |
128000 |
Opus bitrate in bit/s, 500 to 512000; ignored for PCM and FLAC |
opus_complexity |
uint8_t |
2 |
Opus encoder complexity, at most 10; ignored for PCM and FLAC |
line_sense |
bool |
false |
Advertise signal sensing; see SourceRole::set_signal() |
buffer_location |
MemoryLocation |
PREFER_EXTERNAL |
Placement of the capture buffer, the chunk buffer and a FLAC or OPUS encoder's chunk scratch (ESP-IDF only) |
priority |
unsigned |
3 |
FreeRTOS priority for the source task, below the protocol and httpd tasks (ESP-IDF only) |
psram_stack |
bool |
false |
Allocate the source task stack in PSRAM (ESP-IDF only) |
The capture buffer and an encoder's chunk scratch are allocated by the first start() and kept until the client is destroyed, since the capture thread may write at any time; the FLAC encoder itself is created by add_source(). The chunk buffer, which holds a few chunks for the protocol task to send, lives from each start() to its stop().
| Value | Description |
|---|---|
ATTEMPT_TIMEOUT |
Pairing timed out waiting for the next step |
CONCURRENT_ATTEMPT |
The server rejected pairing because another pairing is in progress |
METHOD_NOT_SUPPORTED |
The proposed pairing method is not supported by the client |
PAIRING_CODE_MISMATCH |
The pairing code entered does not match the one this client emitted |
USER_CANCELLED |
The pairing was cancelled by the user: by the server, or locally by cancel_pairing_window() while an attempt was waiting for the gesture |
UNKNOWN |
Unrecognized abort reason from the server, or a client-local protocol error (which also closes the connection) |
Delivered via SendspinClientListener::on_pairing_aborted. An activation naming a pairing
method or format the client does not offer is answered with a pair/abort carrying
method_not_supported and no listener callback at all; the connection stays open.
| Value | Description |
|---|---|
DISPLAY |
The code is shown on a display |
SPEAKER |
The code is spoken, not tone-encoded |
Listed in SendspinClientConfig::pairing_code_out_channels and advertised as the
dynamic_pairing_code descriptor's out_channels.
| Value | Description |
|---|---|
DIGITS |
Six decimal digits the operator types into the server |
QR_CODE |
A pairing token the operator scans from a rendered QR code |
Listed in SendspinClientConfig::pairing_code_formats and advertised as the
dynamic_pairing_code descriptor's formats; the server picks one of them and the chosen
format arrives as the format argument of on_pairing_code.
| Value | Description |
|---|---|
FLAC |
FLAC lossless audio; decodable by the player role and encodable by the source role |
OPUS |
Opus lossy audio; decodable, and encodable by the source role, only in a build with SENDSPIN_ENABLE_OPUS |
PCM |
Raw PCM audio |
UNSUPPORTED |
Unsupported codec |
| Value | Description |
|---|---|
PLAY |
Start playback |
PAUSE |
Pause playback |
STOP |
Stop playback |
NEXT |
Skip to next track |
PREVIOUS |
Skip to previous track |
VOLUME |
Set volume (pass value via the volume field) |
MUTE |
Set mute state (pass value via the muted field) |
REPEAT_OFF |
Disable repeat |
REPEAT_ONE |
Repeat current track |
REPEAT_ALL |
Repeat all tracks |
SHUFFLE |
Enable shuffle |
UNSHUFFLE |
Disable shuffle |
SWITCH |
Switch source |
SEEK |
Seek to an absolute position (pass value via the position_ms field) |
SEEK_RELATIVE |
Seek by a signed offset from the current position (pass value via the offset_ms field) |
| Value | Description |
|---|---|
VOLUME |
Volume adjustment from the server |
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. 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).
| Value | Description |
|---|---|
ANOTHER_SERVER |
Disconnecting to connect to another server |
SHUTDOWN |
Device is shutting down |
RESTART |
Device is restarting |
USER_REQUEST |
User requested disconnect |
UNAUTHORIZED |
Server declared activities or roles the session does not permit, or its pairing record was revoked |
PAIRING_REQUIRED |
Server requested playback but the client requires pairing first |
CONCURRENT_ATTEMPT |
Incoming connection rejected because another is already admitted. Distinct from the same-named SendspinPairAbortReason, which is specific to pairing |
UNPAIRED |
Server unpaired this device via server/unpair |
| Value | Description |
|---|---|
PLAYING |
Audio is playing |
STOPPED |
Audio is stopped |
| Value | Description |
|---|---|
OFF |
Repeat disabled |
ONE |
Repeat current track |
ALL |
Repeat all tracks |
| Value | Description |
|---|---|
JPEG |
JPEG image |
PNG |
PNG image |
| Value | Description |
|---|---|
ALBUM |
Album artwork |
ARTIST |
Artist image |
NONE |
No image source |
| Value | Description |
|---|---|
BEAT |
Musical beat events from tempo/beat tracking |
LOUDNESS |
Overall loudness level |
F_PEAK |
Dominant frequency and its amplitude |
SPECTRUM |
Full frequency spectrum bins |
PEAK |
Energy onset (transient) events |
| Value | Description |
|---|---|
MEL |
Mel scale (perceptual) |
LOG |
Logarithmic scale |
LIN |
Linear scale |
| Value | Description |
|---|---|
PRESENT |
Audio signal detected on the capture input |
ABSENT |
No audio signal on the capture input |
Reported with SourceRole::set_signal(); meaningful only with SourceRoleConfig::line_sense set.
| Value | Description |
|---|---|
NONE |
No logging |
ERROR |
Errors only |
WARN |
Warnings and above |
INFO |
Informational and above (default) |
DEBUG |
Debug and above |
VERBOSE |
All messages |
Set with SendspinClient::set_log_level(). Only affects host builds; ESP-IDF builds use the ESP log level system.
| Value | Description |
|---|---|
PREFER_EXTERNAL |
Prefer SPIRAM, fall back to internal RAM (ESP-IDF only) |
PREFER_INTERNAL |
Prefer internal RAM, fall back to SPIRAM (ESP-IDF only) |
Used by SendspinClientConfig::inbound_ring_location to control where the shared inbound ring and the per-connection fallback buffers are allocated, by SendspinClientConfig::noise_buffer_location to control where the Noise transport's fragment reassembly and send buffers are allocated, by PlayerRoleConfig::decode_buffer_location to control where the player's decode transfer buffer is allocated, by MetadataRoleConfig::buffer_location to control where the metadata role's text blocks are allocated, and by SourceRoleConfig::buffer_location to control where the source's capture and chunk buffers are allocated. Ignored on host platforms (no internal/external distinction).