Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# sendspin-server-cpp

C++ library implementing the server side of the Sendspin synchronized audio streaming protocol, for macOS and Linux (it also builds as an ESP-IDF component). `SendspinServer` (`include/sendspin/server/server.h`) advertises and browses over mDNS, accepts and dials clients, completes the Noise handshake, pairs and approves clients, keeps them time-synced and in groups, and streams each group's host input to its players as PCM, FLAC or Opus, with the controller, metadata, artwork, color, visualizer and source roles. `README.md` has the feature list and the commands.
C++ library implementing the server side of the Sendspin synchronized audio streaming protocol, for macOS and Linux (it also builds as an ESP-IDF component). `SendspinServer` (`include/sendspin/server/server.h`) advertises and browses over mDNS, accepts and dials clients, completes the Noise handshake, pairs and approves clients, keeps them time-synced and in groups, and streams each group's host input to its players as PCM, FLAC or Opus, with the controller, metadata, artwork, color, visualizer and source roles. `README.md` has the feature list, a usage example with the CMake and ESP-IDF integration, and the commands.

## Documents

Expand All @@ -27,7 +27,7 @@ docs/conventions.md - The normative design standards
docs/comment-style.md - The rules for every comment, doc comment and document
docs/internals.md - Threads, cross-thread channels, a connection's lifecycle, a group's playback, core thread order
third_party/ - Vendored micro-resampler and stb (each with its VENDORED.md); re-vendor, never edit
NOTICES.md - The licenses of the vendored and fetched dependencies
NOTICES.md - The licenses of the vendored and fetched dependencies, and the ESP-IDF build's
.claude/skills/ - Review checklists applying docs/conventions.md to a diff (docs-sync, house-patterns, realtime-review, test-standards)
script/clang-tidy.sh - clang-tidy over src/ using build/compile_commands.json
```
Expand Down Expand Up @@ -107,7 +107,7 @@ script/clang-tidy.sh - clang-tidy over src/ using build/compile_commands.js
All pinned; see `cmake/`.

- Boost.Beast and Boost.Asio, host only: the `-cmake` release tarball (URL and SHA256), or a system Boost at or above the pin. Compiled once in `src/beast_separate_compilation.cpp`; any translation unit that includes them links `sendspin_server_cpp_beast` for the separate-compilation definitions. Within the library, only that file, `src/transport.cpp` and `src/transport_asio.h` include Asio or Beast; `src/transport.h` names no Asio type. Tests that run Asio objects on the core thread reach its io_context through `transport_asio.h`. `server_cli`'s dashboard (`web_ui.cpp`) includes them too, with ArduinoJson for its messages.
- ESP-IDF: its `lwip` and `esp_http_server` for the transport.
- ESP-IDF: its `lwip` and `esp_http_server` for the transport, `mbedtls` for AES-GCM, and `espressif/esp-dsp` from the component registry for the visualizer's FFT.
- ArduinoJson, at the sendspin-cpp pin.
- noise-c (esphome-libs fork, the sendspin-cpp pin) built as target `noise_c` from a manual source list: reference backend plus OpenSSL 3 AES-GCM. Initialized with `noise_init_framework()`. On ESP-IDF it is the `esphome/noise-c` component (libsodium-backed) instead, with its name tables and the AESGCM id turned on by `cmake/esp_idf.cmake` and weak hooks in `src/esp/noise_esp.cpp`. A `noise_c` target a parent project defined first is used instead, and must define `NOISE_USE_AES=1`.
- micro-flac at its v0.3.0 release, declared before sendspin-cpp so its declaration wins; `server_cli` links it too, to decode FLAC files.
Expand Down
12 changes: 12 additions & 0 deletions NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ sendspin-server-cpp is licensed under the Apache License 2.0 (`LICENSE`). It inc
### micro-resampler

- Location: `third_party/micro-resampler/` (`VENDORED.md`)
- Source: this vendored copy; there is no public upstream repository
- Version: v0.0.6
- License: Apache-2.0 (`third_party/micro-resampler/LICENSE`)
- Copyright 2025-2026 Kevin Ahrendt
Expand Down Expand Up @@ -122,6 +123,17 @@ CMake FetchContent downloads these at the pinned versions in `cmake/`; each is u
| sendspin-cpp (tests only) | <https://github.com/Sendspin/sendspin-cpp> | Apache-2.0 |
| IXWebSocket (tests only, fetched by sendspin-cpp) | <https://github.com/machinezone/IXWebSocket> | BSD-3-Clause |

## ESP-IDF build

The ESP-IDF component (`cmake/esp_idf.cmake`) builds with these in place of Boost, OpenSSL, the fetched noise-c and pffft, and without micro-opus; the vendored code, ArduinoJson and micro-flac (with micro-ogg-demuxer) are as above.

| Dependency | Source | License |
| --- | --- | --- |
| noise-c, the `esphome/noise-c` registry component | <https://github.com/esphome-libs/noise-c> | MIT |
| libsodium, the `esphome/libsodium` registry component that noise-c brings | <https://github.com/esphome-libs/libsodium> (a port of <https://github.com/jedisct1/libsodium>) | ISC; the port's packaging MIT |
| esp-dsp, the `espressif/esp-dsp` registry component | <https://github.com/espressif/esp-dsp> | Apache-2.0 |
| ESP-IDF, with its FreeRTOS, mbedTLS, lwIP and esp_http_server components | <https://github.com/espressif/esp-idf> | Apache-2.0; FreeRTOS MIT; mbedTLS Apache-2.0 OR GPL-2.0-or-later (<https://github.com/Mbed-TLS/mbedtls>); lwIP BSD-3-Clause |

## System libraries

OpenSSL 3 (Apache-2.0) on every host; avahi-client (LGPL-2.1-or-later) on Linux with discovery enabled; dns_sd, part of macOS, on macOS.
139 changes: 137 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[![CI](https://github.com/Sendspin/sendspin-server-cpp/actions/workflows/ci.yml/badge.svg)](https://github.com/Sendspin/sendspin-server-cpp/actions/workflows/ci.yml)

C++ library implementing the server side of the [Sendspin synchronized audio streaming protocol](https://www.sendspin-audio.com/), so that an application (a media player, a sound server sink, a small daemon) can stream synchronized audio to Sendspin players. Targets macOS and Linux. For the client side, see [sendspin-cpp](https://github.com/Sendspin/sendspin-cpp).
C++ library implementing the server side of the [Sendspin synchronized audio streaming protocol](https://www.sendspin-audio.com/), so that an application (a media player, a sound server sink, a small daemon) can stream synchronized audio to Sendspin players. Targets macOS and Linux, and builds as an ESP-IDF component. For the client side, see [sendspin-cpp](https://github.com/Sendspin/sendspin-cpp).

## Status

Expand All @@ -18,6 +18,141 @@ Early development. The public API changes between releases without deprecation,
- Source role: a client's captured audio routed into a group's live input, played a fixed latency after its capture, the source's clock drift corrected (ASRC on a host, single frames dropped or repeated on ESP-IDF)
- `examples/server_cli`: a command-line and browser-controlled host on the public API

## Usage Example

A host that plays a 440 Hz tone to every client that enables unpaired access. `include/sendspin/server/server.h` documents each call and the threading contract, and `examples/server_cli` is a complete host.

```cpp
#include <sendspin/server/server.h>

#include <cmath>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <map>
#include <numbers>
#include <optional>
#include <string>
#include <vector>

using namespace sendspin::server;

// Holds the server key and the client records. A real host stores them on disk, so that clients
// see the same server across runs.
class MemoryProvider : public PersistenceProvider {
public:
std::optional<std::vector<uint8_t>> load_blob(const std::string& key) override {
auto it = blobs_.find(key);
if (it == blobs_.end()) {
return std::nullopt;
}
return it->second;
}

bool save_blob(const std::string& key, const uint8_t* data, size_t len) override {
blobs_[key].assign(data, data + len);
return true;
}

private:
std::map<std::string, std::vector<uint8_t>> blobs_;
};

// Called on the server's dispatch thread, one call at a time
class PrintingListener : public ServerListener {
public:
void on_client_connected(const ClientInfo& client) override {
std::printf("connected: %s\n", client.name.c_str());
}

void on_pairing_required(const ClientInfo& client) override {
std::printf("needs pairing: %s\n", client.name.c_str());
}
};

int main() {
MemoryProvider provider;
PrintingListener listener;
SendspinServer server;

ServerConfig config;
config.name = "Living Room";
// Plays to every client that enables unpaired access; otherwise approve() or pair each client
config.approve_all_clients = true;
if (server.start(config, &provider, &listener) != StartResult::OK) {
return 1;
}

// On a host the server advertises itself over mDNS and dials the clients it finds; every
// approved client joins the server's group
constexpr uint32_t RATE = 48000;
InputOptions options{.format = {.sample_rate = RATE, .channels = 2}};
OpenedInput opened = server.open_input(*server.default_group(), options);
if (opened.result != OpenInputResult::OK) {
return 1;
}
GroupInput& input = *opened.input;

// A buffered input takes PCM as far ahead as its players can hold: write until it refuses,
// then wait for room
std::vector<int16_t> block(RATE / 50 * 2);
for (uint64_t pos = 0;;) {
for (size_t i = 0; i < block.size() / 2; ++i) {
double t = static_cast<double>(pos + i) / RATE;
auto s = static_cast<int16_t>(3000 * std::sin(2 * std::numbers::pi * 440 * t));
block[2 * i] = block[2 * i + 1] = s;
}
size_t done = 0;
while (done < block.size() / 2) {
WriteResult result = input.write(block.data() + 2 * done, block.size() / 2 - done);
done += result.frames;
if (result.state == InputState::FULL) {
input.wait_writable(100'000);
} else if (result.state != InputState::OPEN) {
// The group's last member left it (STOP_LATCHED), or the server closed the input
return 0;
}
}
pos += done;
}
}
```

### Host (CMake)

Consume the library as a subproject, here through FetchContent (`tests/consumer/` is such a project); the requirements under [Host (macOS/Linux)](#host-macoslinux) apply:

```cmake
cmake_minimum_required(VERSION 3.25)
project(my_host LANGUAGES C CXX)

include(FetchContent)
FetchContent_Declare(
sendspin_server_cpp
GIT_REPOSITORY https://github.com/Sendspin/sendspin-server-cpp.git
GIT_TAG main # pin a commit
)
FetchContent_MakeAvailable(sendspin_server_cpp)

add_executable(my_host main.cpp)
target_link_libraries(my_host PRIVATE sendspin_server_cpp::sendspin_server_cpp)
```

### ESP-IDF

The library is an ESP-IDF component (`cmake/esp_idf.cmake`, with the repository's `Kconfig`) and requires ESP-IDF 5.5 or later. The repository's root is the component, named after its directory, or a wrapper component includes `cmake/esp_idf.cmake` and sources the root's `Kconfig`, fixing the name whatever the checkout's directory; `tests/esp_idf/` is a minimal project that builds it the second way, as `components/sendspin_server`. The component builds with the embedded profile's limits (`src/profile.h`), without Opus and without an mDNS backend: supply a `DiscoveryProvider` in `ServerConfig::discovery` (`include/sendspin/server/discovery.h`), or dial clients with `connect_url()`.

The project supplies the component's dependencies and settings:

- `esphome/noise-c` (which brings `esphome/libsodium`) and `espressif/esp-dsp` from the component registry, in a component's `idf_component.yml`, at the versions in `tests/esp_idf/main/idf_component.yml`
- micro-flac at its v0.3.0 tag with its submodules, in a component directory named `micro-flac`, and `third_party/micro-resampler`, both in `EXTRA_COMPONENT_DIRS`
- ArduinoJson's source tree at v7.4.1 (`cmake/dependencies.cmake`'s pin), named by `SENDSPIN_SERVER_ARDUINOJSON_SOURCE_DIR` before `project()` or in the environment
- `CONFIG_COMPILER_CXX_EXCEPTIONS`, and a `CONFIG_DSP_MAX_FFT_SIZE` of at least 4096 (`CONFIG_DSP_MAX_FFT_SIZE_4096`)
- `CONFIG_PTHREAD_TASK_CORE_DEFAULT` left at ESP-IDF's default of -1 (no core affinity), or the build fails
- `CONFIG_LWIP_MAX_SOCKETS` and `CONFIG_LWIP_MAX_ACTIVE_TCP` raised from ESP-IDF's defaults, as below

`CONFIG_LWIP_MAX_SOCKETS` must be raised from ESP-IDF's default of 10: the listener's httpd takes a socket for each accepted connection in its handshake and each activated one, plus three of its own (19), and a build with fewer fails; the dials and mDNS need more, as `tests/esp_idf/sdkconfig.defaults` allows. `CONFIG_LWIP_MAX_ACTIVE_TCP` must be raised from 16 too: those sessions, the connections waiting in httpd's backlog (as many as accepted connections in their handshake) and the dials in their handshake (28) must fit, or the build fails, and each activated dialed connection takes one more.

## Documentation

- **[Internals](docs/internals.md)**: threads, cross-thread channels, a connection's lifecycle and a group's playback
Expand All @@ -43,7 +178,7 @@ This builds the library (target `sendspin_server_cpp`, alias `sendspin_server_cp

Dependencies (fetched automatically via CMake FetchContent at pinned versions): Boost.Beast and Boost.Asio (a system Boost 1.90 or later is used when installed), ArduinoJson, noise-c, micro-flac, micro-opus and pffft. micro-resampler and stb are vendored in `third_party/`; `NOTICES.md` lists every dependency's license.

The library also builds as an ESP-IDF component (`cmake/esp_idf.cmake`); `tests/esp_idf/` is a minimal project that builds it. The project raises `CONFIG_LWIP_MAX_SOCKETS` from ESP-IDF's default of 10: the listener's httpd takes a socket for each accepted connection in its handshake and each activated one, plus three of its own (19 on the embedded profile), and a build with fewer fails; the dials and mDNS need more, as `tests/esp_idf/sdkconfig.defaults` allows. It raises `CONFIG_LWIP_MAX_ACTIVE_TCP` from 16 too: those sessions, the connections waiting in httpd's backlog (as many as accepted connections in their handshake) and the dials in their handshake (28 on the embedded profile) must fit, or the build fails, and each activated dialed connection takes one more.
The library also builds as an ESP-IDF component; see [ESP-IDF](#esp-idf).

### Tests

Expand Down
Loading