Standalone C++ library implementing the Sendspin synchronized audio streaming protocol. Builds on both ESP-IDF (ESP32) and host platforms (macOS/Linux). Designed to be consumed by ESPHome but has no ESPHome dependencies.
The library provides SendspinClient as the main public API. It handles the full protocol lifecycle: WebSocket connections, time synchronization, audio decoding/sync, and message routing.
SendspinClient(client.h): main orchestration class, owns connections, time sync, and message routingPlayerRole(player_role.h): audio streaming role, ownsSyncTask, writes decoded audio viaon_audio_writecallbackControllerRole(controller_role.h): sends playback commands to the serverMetadataRole(metadata_role.h): receives track metadata and progressArtworkRole(artwork_role.h): receives album artwork imagesVisualizerRole(visualizer_role.h): receives spectrum/beat visualization dataColorRole(color_role.h): receives audio-derived RGB color palette from the serverSyncTask(sync_task.h): decodes encoded audio, synchronizes to server timestamps, writes PCM via audio write callbackSendspinConnection(connection.h): abstract WebSocket connection baseSendspinServerConnection/SendspinClientConnection: platform-specific WebSocket transports (ESP usesesp_websocket_client/esp_http_server, host uses IXWebSocket)SendspinTimeFilter(time_filter.h): 2D Kalman filter for NTP-style time syncSendspinTimeBurst(time_burst.h): burst-based time message coordinatorSendspinDecoder(decoder.h): FLAC/Opus/PCM decoder wrapper
Roles are added to the client at runtime via add_player(), add_metadata(), etc. Each role receives a SendspinClient* at construction time and uses it to access shared services (time sync, state publishing, message sending). The consumer provides behavior by implementing listener interfaces (PlayerRoleListener, MetadataRoleListener, etc.) and setting them via set_listener(). Required callbacks are pure virtual; optional callbacks have default no-op implementations. The client dispatches messages to roles via null-pointer checks on role pointers.
Roles can be disabled at compile time via SENDSPIN_ENABLE_* cmake options (host build) or Kconfig entries (ESP-IDF build). When a role is disabled, its source files are not compiled and its add_*() declaration, accessor, and unique_ptr member are removed from client.h. #ifdef guards live in exactly two places: cmake/sources.cmake (source lists) and include/sendspin/client.h / src/client.cpp (dispatch points).
// Implement listener interfaces
struct MyPlayerListener : PlayerRoleListener {
size_t on_audio_write(uint8_t* data, size_t len, uint32_t timeout_ms) override {
return audio_output.write(data, len, timeout_ms);
}
void on_stream_start() override { /* ... */ }
};
struct MyMetadataListener : MetadataRoleListener {
void on_metadata(const ServerMetadataStateObject& m) override { /* ... */ }
};
struct MyNetworkProvider : SendspinNetworkProvider {
bool is_network_ready() override { return true; }
};
MyPlayerListener player_listener;
MyMetadataListener metadata_listener;
MyNetworkProvider network_provider;
SendspinClient client(config);
auto& player = client.add_player(player_config);
player.set_listener(&player_listener);
auto& metadata = client.add_metadata();
metadata.set_listener(&metadata_listener);
client.set_network_provider(&network_provider);
client.add_controller();
client.start_server();The platform (e.g., ESPHome) provides:
- A
PlayerRoleListenerimplementation withon_audio_write()to receive decoded PCM audio - An optional
SendspinPersistenceProviderfor saving/loading preferences - A
SendspinNetworkProviderfor network readiness - An optional
SendspinClientListenerfor high-performance WiFi power management callbacks - Playback progress feedback via
notify_audio_played()
include/sendspin/ - Public API headers (client.h, config.h, types.h, *_role.h)
src/ - Cross-platform source files (.cpp) and private headers (.h)
src/platform/ - Platform abstraction headers and host-only source files
src/esp/ - ESP-IDF networking implementations and headers
src/host/ - Host (IXWebSocket) networking implementations and headers
cmake/ - CMake modules (sources.cmake, host.cmake)
examples/common/ - Shared PortAudio audio sink used by host examples
examples/basic_client/ - Standalone host example with PortAudio audio output
examples/tui_client/ - Terminal UI host example with PortAudio audio output
- Public (
include/sendspin/):client.h,config.h,types.h, and role headers (player_role.h,controller_role.h,metadata_role.h,artwork_role.h,visualizer_role.h,color_role.h). These are the consumer-facing API.config.hcontains all configuration structs (SendspinClientConfigand role configs). Each role header defines its own protocol types (enums, structs, conversion functions).types.hcontains shared types used across the client and roles. - Private (
src/): All internal headers (decoder, sync_task, time_filter, ring buffers, protocol_messages, etc.). Not exposed to consumers.protocol_messages.hcontains message envelope structs, internal protocol enums, and protocol function declarations. - Platform-specific (
src/esp/,src/host/): Networking headers with the same names (client_connection.h,server_connection.h,ws_server.h) but different implementations per platform.
Headers in src/platform/ use #ifdef ESP_PLATFORM to provide unified APIs across platforms:
logging.h:SS_LOGE/SS_LOGW/SS_LOGI/SS_LOGD/SS_LOGVmacros (ESP:esp_log.h, host:printf-based)memory.h:platform_malloc/platform_realloc/platform_malloc_internal/platform_realloc_internal/platform_free(ESP: SPIRAM-preferring or internal-RAM-preferringheap_caps_malloc_prefer, host: standardmalloc).PlatformBufferaccepts aMemoryLocation(defined ininclude/sendspin/types.h) to select the preference.thread.h: threading utilitiestime.h: time utilitiesbase64.h: base64 encoding/decodingtypes.h: platform type abstractionsspsc_ring_buffer.h: single-producer/single-consumer ring buffer (ESP: FreeRTOSxRingbuffer, host: mutex/condition variable)thread_safe_queue.h: thread-safe queue (ESP: FreeRTOS queue, host: mutex/condition variable)event_flags.h: event flag group (ESP: FreeRTOS event group, host: mutex/condition variable)shadow_slot.h: mutex-protected slot for publishing state from one thread and reading from another
Core source files in src/ have no #ifdef ESP_PLATFORM guards; all platform differences are isolated to the platform layer and the src/esp//src/host/ directories.
- ESP-IDF: Used as an IDF component via
idf_component.yml. Sources defined incmake/sources.cmake. - Host (CMake):
cmake -B build && cmake --build build. Fetches dependencies (ArduinoJson, micro-flac, micro-opus, IXWebSocket) via FetchContent. - ESP dependencies: ArduinoJson, esp_websocket_client, micro-flac, micro-opus, esp_http_server, mbedtls, pthread, esp_ringbuf
- Host dependencies: ArduinoJson, micro-flac, micro-opus, IXWebSocket, pthreads
- C++20 (
gnu++20on ESP,cxx_std_20on host) - Namespace:
sendspin - Logging: Platform macros
SS_LOGE,SS_LOGW,SS_LOGI,SS_LOGD,SS_LOGV(not rawESP_LOG*) - Memory:
platform_malloc/platform_realloc/platform_malloc_internal/platform_realloc_internal/platform_freefromplatform/memory.h(not rawheap_caps_malloc). The_internalvariants prefer internal RAM with SPIRAM fallback; the unsuffixed variants do the reverse.PlatformBufferandTransferBufferaccept aMemoryLocationargument to select the preference. - Threading:
std::mutex,std::thread(via pthreads on both platforms). ESP build also uses FreeRTOS primitives (xRingbuffer, queues, event groups) for performance via the platform abstraction layer. - Role composition: Roles are added at runtime via
add_player(),add_metadata(), etc. Roles can be disabled at compile time viaSENDSPIN_ENABLE_*cmake options / Kconfig entries. Audio codec dependencies (micro-flac, micro-opus) are only linked when the player role is enabled. - Apache 2.0 license headers on all files
Configured in .pre-commit-config.yaml:
end-of-file-fixerandtrailing-whitespaceclang-format(v18) for C/C++ files undersrc/andinclude/markdownlint-fixfor markdown files