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) 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, ColorRoleListenerOnly 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.client_id = "my-device-mac-addr"; // Unique identifier (e.g., MAC address)
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));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},
{SendspinCodecFormat::OPUS, 2, 48000, 16},
{SendspinCodecFormat::PCM, 2, 44100, 16},
{SendspinCodecFormat::PCM, 2, 48000, 16},
};
player_config.audio_buffer_capacity = 1000000; // Ring buffer size in bytes (default: 1000000)
player_config.fixed_delay_us = 0; // Fixed delay offset in microseconds
player_config.initial_static_delay_ms = 0; // Initial user-adjustable delay
player_config.extra_startup_silence_ms = 50; // Extra startup silence for decode headroom (default: 50)
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 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 |
codec_header |
std::optional<std::string> |
Codec-specific header data |
Call is_complete() on the object to check if all fields have values.
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.
auto& metadata = client.add_metadata();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.
Receives real-time beat, loudness, dominant-frequency, onset, and spectrum data synchronized to playback.
VisualizerSupportObject vis_support;
vis_support.types = {
VisualizerDataType::BEAT,
VisualizerDataType::LOUDNESS,
VisualizerDataType::F_PEAK,
VisualizerDataType::SPECTRUM,
VisualizerDataType::PEAK,
};
vis_support.buffer_capacity = 32768; // Total ring buffer bytes; ~1/3 holds wire data
vis_support.rate_max = 30; // Set to the display refresh rate
vis_support.spectrum = VisualizerSpectrumConfig{
.n_disp_bins = 32,
.scale = VisualizerSpectrumScale::MEL,
.f_min = 40,
.f_max = 16000,
};
auto& visualizer = client.add_visualizer({.support = vis_support});The advertised support object is the starting format. To change it at runtime, call
request_format() with only the fields you want to change; omitted fields keep their
current value on the server:
visualizer.request_format({.rate_max = 15}); // Halve the frame rate
visualizer.request_format({
.types = {{VisualizerDataType::BEAT, VisualizerDataType::LOUDNESS}},
});While a stream is active the server replies with a fresh stream/start, so
on_visualizer_stream_start() fires again with the updated
ServerVisualizerStreamObject. If no stream is active, the server remembers the request
and applies it to the next stream.
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();The on_audio_write method is the only pure virtual (required) method in the entire library.
struct MyPlayerListener : PlayerRoleListener {
// REQUIRED: Write decoded PCM audio 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 static delay.
void on_static_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., fromstd::chrono::steady_clock)
This method is thread-safe and is expected to be called from an audio callback thread.
struct MyMetadataListener : MetadataRoleListener {
void on_metadata(const ServerMetadataStateObject& 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 ServerMetadataStateObject 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> |
Track title |
artist |
std::optional<std::string> |
Track artist |
album_artist |
std::optional<std::string> |
Album artist |
album |
std::optional<std::string> |
Album name |
artwork_url |
std::optional<std::string> |
Artwork URL |
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.
A field is nullopt when the server has not provided it or has explicitly cleared it. Listeners that mirror metadata into display state should overwrite the displayed value on every on_metadata() call (using e.g. value_or("")) so that server clears propagate.
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, stream clear, and disconnect.
| 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, cleared, or connection lost | 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 stream-level clears 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 is never interrupted mid-fade. - A stream end or stream clear 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. 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 connection is lost and cached colors are dropped.
// 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 |
A field is nullopt when the server has not provided it or has explicitly cleared it; listeners do not need to distinguish those cases.
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; }
};Allows the library to persist and restore state across reboots. Useful on embedded devices.
struct MyPersistenceProvider : SendspinPersistenceProvider {
// Save/load the hash of the last server that was playing audio.
// Used to prioritize reconnection to the same server.
bool save_last_server_hash(uint32_t hash) override {
return nvs_write("last_server", hash);
}
std::optional<uint32_t> load_last_server_hash() override {
uint32_t hash;
if (nvs_read("last_server", &hash)) return hash;
return std::nullopt;
}
// Save/load the player's user-adjustable static delay.
bool save_static_delay(uint16_t delay_ms) override {
return nvs_write("static_delay", delay_ms);
}
std::optional<uint16_t> load_static_delay() override {
uint16_t delay;
if (nvs_read("static_delay", &delay)) return delay;
return std::nullopt;
}
};Receives client-level events.
struct MyClientListener : SendspinClientListener {
// Called when group state changes (playback state, group name, etc.)
void on_group_update(const GroupUpdateObject& group) override {
if (group.playback_state) update_playback_indicator(*group.playback_state);
if (group.group_name) update_group_display(*group.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);
}
};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 itself never invokes a listener (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); // Optional// Start the role threads and arm the WebSocket server (it comes up on the first loop() tick
// after the network provider reports ready).
// 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.
client.connect_to("ws://192.168.1.10:8928/sendspin");
// Main loop: call loop() periodically to process events.
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();start_server() is a deprecated alias of start().
stop() is synchronous: when it returns the client is fully stopped. It sends a client/goodbye (reason shutdown) to every peer, waits up to a short bound (50 ms per peer) for those sends to complete, then closes the server and every connection regardless, 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()) before returning. It is a no-op on a stopped client. is_started() reports the state, and loop() is a no-op while stopped.
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. Besides the goodbye bound 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 outbound
connect_to()connection's transport stop, which is synchronous (esp_websocket_client_stop()/ix::WebSocket::stop()). - 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.
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 start() gets false and starts nothing; one that calls stop(), connect_to(), or disconnect() is ignored. is_started() reads false throughout and is safe to call from any thread. Call stop() only from the main loop thread: from a role-thread callback it would join the calling thread.
on_request_high_performance() and on_release_high_performance() can fire while the client holds an internal lock, so their bodies must only toggle the platform networking mode and must not call any client or role method.
Destroying a running client performs the transport half of stop() (goodbye, bounded wait, close, join) and dispatches no teardown or clear callback. 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.
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 server clamps seeks to the seekable range and ignores any command not present in the controller state's supported_commands.
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()) { /* ... */ }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_static_delay(50); // User-adjustable delay in ms
// Enable/disable static 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_static_delay_adjustable(true);Report the client's overall state to the server. Use this when your device switches to an external audio source or encounters an error:
client.update_state(SendspinClientState::EXTERNAL_SOURCE); // Playing from another source
client.update_state(SendspinClientState::ERROR); // Error condition
client.update_state(SendspinClientState::SYNCHRONIZED); // Back to normalThe client and roles expose query methods for polling state in your main loop or UI update cycle:
// Client state
bool connected = client.is_connected(); // Active connection with completed handshake
bool synced = client.is_time_synced(); // Time filter has received at least one measurement
const GroupUpdateObject& group = client.get_group_state(); // Group id, name, playback state (all optional)
// Player state
uint8_t vol = player.get_volume();
bool muted = player.get_muted();
uint16_t delay = player.get_static_delay_ms();
int32_t fixed = player.get_fixed_delay_us();
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);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 |
| All other listener methods | Main loop thread |
PlayerRole::notify_audio_played() is thread-safe and is designed to be called from an audio output callback thread.
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).
A minimal integration that receives and discards audio:
#include "sendspin/client.h"
#include "sendspin/player_role.h"
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.client_id = "minimal-example";
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, sync task, audio ring buffer)
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=OFFAvailable options (all ON by default):
| Option | Controls |
|---|---|
SENDSPIN_ENABLE_PLAYER |
Player role, audio decoders (micro-flac, micro-opus), sync task |
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 |
When SENDSPIN_ENABLE_PLAYER is OFF, the micro-flac and micro-opus dependencies are not fetched.
Role flags are exposed via Kconfig under Component config → sendspin-cpp:
CONFIG_SENDSPIN_ENABLE_PLAYER=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
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.
| Field | Type | Default | Description |
|---|---|---|---|
client_id |
std::string |
— | Unique client identifier (e.g., MAC address) |
name |
std::string |
— | 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). |
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) |
websocket_priority |
unsigned |
5 |
FreeRTOS priority for the WebSocket client task (ESP-IDF only) |
server_port |
uint16_t |
8928 |
WebSocket server port |
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) |
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. 0 disables the check. |
websocket_payload_location |
MemoryLocation |
PREFER_EXTERNAL |
Memory placement for the per-connection WebSocket payload reassembly buffer (sized to the largest incoming frame, holds raw audio chunks delivered by httpd). 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. |
json_arena_size |
size_t |
2048 |
Size in bytes of a fixed internal-RAM scratch buffer used to parse incoming JSON protocol messages, instead of the default PSRAM. Costs this many bytes of internal RAM permanently but removes PSRAM traffic from the network task on every message. Messages too large for the budget fall back to PSRAM; the default covers steady-state traffic (including the FLAC stream-start header), while large track-metadata messages may spill over (but those arrive only once per song). Set to 0 to disable and keep PSRAM-only behaviour. On host there is no PSRAM distinction, so the arena is just a fixed scratch buffer for the parse (still used, harmless). |
Configuration passed to client.add_player().
| Field | Type | Default | Description |
|---|---|---|---|
audio_formats |
std::vector<AudioSupportedFormatObject> |
{} |
Audio formats the player supports; advertised to the server during the hello handshake. The server selects one when establishing a stream. |
audio_buffer_capacity |
size_t |
1000000 |
Internal ring buffer size in bytes. Larger buffers absorb more jitter at the cost of memory. |
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 static delay. |
initial_static_delay_ms |
uint16_t |
0 |
Initial value for the user-adjustable static 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. |
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_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, PNG, or BMP) |
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. |
Configuration passed to client.add_visualizer().
| Field | Type | Default | Description |
|---|---|---|---|
support |
VisualizerSupportObject |
- | Visualizer capabilities advertised to the server during the hello handshake |
psram_stack |
bool |
false |
Allocate drain thread stack in PSRAM (ESP-IDF only) |
priority |
unsigned |
2 |
FreeRTOS priority for the drain thread (ESP-IDF only) |
VisualizerSupportObject fields:
| Field | Type | Description |
|---|---|---|
types |
std::vector<VisualizerDataType> |
Data stream types to receive (BEAT, LOUDNESS, F_PEAK, SPECTRUM, PEAK) |
buffer_capacity |
size_t |
Total RAM budget in bytes for the internal ring buffer. Per-entry overhead means only ~1/3 holds wire data; the client advertises that effective capacity to the server |
rate_max |
uint16_t |
Maximum periodic frames per second; set to the display refresh rate |
spectrum |
std::optional<VisualizerSpectrumConfig> |
Spectrum analysis parameters; required when SPECTRUM is in types |
VisualizerSpectrumConfig fields:
| Field | Type | Description |
|---|---|---|
n_disp_bins |
uint8_t |
Number of frequency bins to receive |
scale |
VisualizerSpectrumScale |
Frequency scale (MEL, LOG, or LIN) |
f_min |
uint16_t |
Minimum frequency in Hz |
f_max |
uint16_t |
Maximum frequency in Hz |
| Value | Description |
|---|---|
FLAC |
FLAC lossless audio |
OPUS |
Opus lossy audio |
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_STATIC_DELAY |
Static delay adjustment from the server |
These represent commands the server can send to the player. The player advertises which commands it supports. Enable SET_STATIC_DELAY with player.set_static_delay_adjustable(true).
| Value | Description |
|---|---|
SYNCHRONIZED |
Normal synchronized state |
ERROR |
Error state |
EXTERNAL_SOURCE |
Playing from an external source |
| Value | Description |
|---|---|
ANOTHER_SERVER |
Disconnecting to connect to another server |
SHUTDOWN |
Device is shutting down |
RESTART |
Device is restarting |
USER_REQUEST |
User requested disconnect |
| 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 |
BMP |
BMP 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 |
|---|---|
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::websocket_payload_location to control where the per-connection WebSocket payload reassembly buffer is allocated, and by PlayerRoleConfig::decode_buffer_location to control where the player's decode transfer buffer is allocated. Ignored on host platforms (no internal/external distinction).