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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ The library provides `SendspinClient` as the main public API. It handles the ful
- `NoiseTransport` (`noise_transport.h`): per-connection encrypted framing, owns fragmentation and reassembly around the session
- `RecordStore` (`record_store.h`): pairing records and the Pairing PSK (configured, stored, or generated), seeded from the client config and the persistence provider
- `Inbox` / `InboxSlot` / `GenerationSlot` (`inbox.h`): single-mutex mailbox for all main-loop-bound cross-thread state - atomic topic bitmask polled lock-free by `loop()`, plus a fixed event ring for ordered lifecycle events
- `ScheduledStateSlot` (`scheduled_state_slot.h`): the metadata and color roles' generation-stamped slot, which also keeps the state waiting for its server-clock timestamp, and `drain_scheduled_state()`, their shared peek / decide-with-no-lock / commit drain
- `RoleTeardown` / `TeardownTracker` (`teardown_tracker.h`): every role's teardown generation and the main-loop half of a role's teardown
- `MessageTracer` (`message_trace.h`): one connection's handle on the opt-in plaintext message trace (`SendspinClientConfig::message_trace`), built only with `SENDSPIN_ENABLE_MESSAGE_TRACE`
- `SendspinTimeFilter` (`time_filter.h`): 2D Kalman filter for NTP-style time sync
Expand Down Expand Up @@ -95,7 +96,7 @@ Core source files in `src/` have no `#ifdef ESP_PLATFORM` guards; all platform d

- **ESP-IDF**: Used as an IDF component via `idf_component.yml`. Sources defined in `cmake/sources.cmake`.
- **Host (CMake)**: `cmake -B build && cmake --build build`. Uses ccache when installed (`SENDSPIN_USE_CCACHE`, default on when standalone). Fetches dependencies via FetchContent: ArduinoJson, noise-c, IXWebSocket, micro-flac for the player or source role, and micro-opus for the player or source role when `SENDSPIN_ENABLE_OPUS` is on.
- **Tests**: `cmake -B build-tests -DSENDSPIN_BUILD_TESTS=ON -DENABLE_SANITIZERS=ON -DBUILD_EXAMPLES=OFF .`, then `cmake --build build-tests --target sendspin_tests` and `ctest --test-dir build-tests --output-on-failure`. The target also builds `sendspin_alloc_tests`, which counts the library's `operator new` calls from accept to admission, from `connect_to()` to admission, for a `client/state`, for a time burst, for a controller `server/state`, for `group/update`, for the `stream/*` messages, for the sync task's first FLAC codec header, for a FLAC `stream/start` the player takes, for a `stream/start` section the connection's roles do not take, for the first spectrum frame, and through the record flush that follows each of a playback handoff to another paired server, a `server/unpair`, a `pairing_psk` pairing and a dynamic pairing code pairing in each emission format; `SENDSPIN_ALLOC_REPORT=1` prints them grouped by call site.
- **Tests**: `cmake -B build-tests -DSENDSPIN_BUILD_TESTS=ON -DENABLE_SANITIZERS=ON -DBUILD_EXAMPLES=OFF .`, then `cmake --build build-tests --target sendspin_tests` and `ctest --test-dir build-tests --output-on-failure`. The target also builds `sendspin_alloc_tests`, which counts the library's `operator new` calls from accept to admission, from `connect_to()` to admission, for a `client/state`, for a time burst, for a controller `server/state`, for `group/update`, for metadata `server/state` (a first state, a second, a scheduled one and one without text), for the `stream/*` messages, for the sync task's first FLAC codec header, for a FLAC `stream/start` the player takes, for a `stream/start` section the connection's roles do not take, for the first spectrum frame, and through the record flush that follows each of a playback handoff to another paired server, a `server/unpair`, a `pairing_psk` pairing and a dynamic pairing code pairing in each emission format; `SENDSPIN_ALLOC_REPORT=1` prints them grouped by call site.
- **ThreadSanitizer tests**: `cmake -B build-tsan -DSENDSPIN_BUILD_TESTS=ON -DENABLE_TSAN=ON -DBUILD_EXAMPLES=OFF .`, then `cmake --build build-tsan --target sendspin_tests` and `TSAN_OPTIONS=halt_on_error=1 ctest --test-dir build-tsan --output-on-failure`. `ENABLE_TSAN` applies the thread sanitizer to every target, including the fetched dependencies, and cannot be combined with `ENABLE_SANITIZERS`.
- **Message trace**: `-DSENDSPIN_ENABLE_MESSAGE_TRACE=ON` compiles in `SendspinClientConfig::message_trace` and `tests/test_message_trace.cpp`; CI's test and TSan jobs build it on, the build and no-opus jobs off.
- **Stack budgets**: the ESP task stack defaults in `config.h` are static call-graph bounds from `tools/stack_usage/` (recipe and per-task figures in its README); re-derive them, and update the README table with them, after a change that deepens a task's call chain.
Expand Down
12 changes: 7 additions & 5 deletions docs/conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,10 @@ checklists in `.claude/skills/` apply these standards to a diff.
- An event or a slot payload whose delivery must not survive its producer
being torn down carries the producer's teardown generation and is checked
against it at the drain (`push_event_or_log()` / `event_is_current()` for
events, `GenerationSlot` for a role's slots), rather than relying on the
ring or the slot being reset: a teardown that leaves other producers running
cannot reset it, and a drain can take a slot on either side of a teardown.
events, `GenerationSlot` or `ScheduledStateSlot` for a role's slots), rather
than relying on the ring or the slot being reset: a teardown that leaves
other producers running cannot reset it, and a drain can take a slot on
either side of a teardown.
- Callback dispatch must tolerate re-entrant teardown: a listener callback may
call back into the client. See "Re-entrant Teardown During Callback
Dispatch" in `docs/internals.md` for the guard patterns in use.
Expand All @@ -91,8 +92,9 @@ checklists in `.claude/skills/` apply these standards to a diff.
stamps the role's events and slot payloads with the new teardown generation,
and the main loop runs the role's own half once per generation, through the
role's `TeardownTracker` (`catch_up_teardown()`), before acting on anything
stamped with it. A role drain takes its slot before it catches up, then
applies only a payload stamped with the current generation.
stamped with it. A role drain takes (or peeks at) its slot before it
catches up, then applies only a payload stamped with the current
generation.
- A protocol-task step has a bounded wait or none: the Noise DH operations, a
ring acquire bounded by `INBOUND_ACQUIRE_TIMEOUT_MS`, and a transport send
bounded by the transport's own send timeout; no step waits on an
Expand Down
31 changes: 23 additions & 8 deletions docs/integration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,12 @@ auto& controller = client.add_controller();

### Metadata Role (Track Information)

Receives track metadata (title, artist, album, progress, etc.) from the server.
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.

```cpp
auto& metadata = client.add_metadata();
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});
```

### Artwork Role (Album Art)
Expand Down Expand Up @@ -315,17 +317,19 @@ The `ServerMetadataStateObject` contains these fields (all optional except `time
| 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 |
| `title` | `std::optional<std::string>` | Track title, at most 255 bytes |
| `artist` | `std::optional<std::string>` | Track artist, at most 255 bytes |
| `album_artist` | `std::optional<std::string>` | Album artist, at most 255 bytes |
| `album` | `std::optional<std::string>` | Album name, at most 255 bytes |
| `artwork_url` | `std::optional<std::string>` | 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 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_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:
Expand Down Expand Up @@ -1426,6 +1430,17 @@ Each entry in `audio_formats` is an `AudioSupportedFormatObject`:

---

### MetadataRoleConfig

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 `ServerMetadataStateObject`. 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 public state's string, 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. |

---

### ArtworkRoleConfig

Configuration passed to `client.add_artwork()`.
Expand Down Expand Up @@ -1675,4 +1690,4 @@ Set with `SendspinClient::set_log_level()`. Only affects host builds; ESP-IDF bu
| `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, 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).
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).
Loading
Loading