Skip to content
Merged
31 changes: 29 additions & 2 deletions docs/integration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,8 @@ struct MyControllerListener : ControllerRoleListener {

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.

```cpp
struct MyArtworkListener : ArtworkRoleListener {
// THREAD SAFETY: Called from the dedicated decode thread.
Expand All @@ -282,8 +284,8 @@ struct MyArtworkListener : ArtworkRoleListener {
}

// Called from the main loop thread once the server display timestamp is reached.
// Swap the decoded image onto the display.
void on_image_display(uint8_t slot) override {
// 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]);
}

Expand All @@ -294,6 +296,27 @@ struct MyArtworkListener : ArtworkRoleListener {
};
```

**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; any newer payload that arrives is buffered latest-wins and delivered only after the consumer calls `ArtworkRole::frame_done(slot)` from the main loop -- e.g. once a cross-fade animation finishes. A clear is itself a delivery and supersedes any un-acked frame, so exactly one `frame_done()` is owed after it. There is no timeout: the acknowledgment is the contract.

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:

```cpp
// 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.

### VisualizerRoleListener

```cpp
Expand Down Expand Up @@ -608,6 +631,8 @@ Most listener callbacks fire on the main loop thread (the thread calling `client

`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).

## Minimal Example

A minimal integration that receives and discards audio:
Expand Down Expand Up @@ -777,6 +802,8 @@ Each entry in `preferred_formats` is an `ImageSlotPreference`. The slot/channel
| `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](#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. |

---

Expand Down
3 changes: 2 additions & 1 deletion docs/internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,8 @@ The `awaiting_sync_idle_events` list (on `PlayerRole::Impl`) is the key ordering
- **ControllerRole**: Takes the latest `ServerStateControllerObject` from its `InboxSlot`, fires `on_controller_state()`. The disconnect clear is not handled here; it arrives as a `CONTROLLER_CLEARED` event on the shared ring, whose `handle_cleared_event()` fires `on_controller_state_clear()` (deferred from `cleanup()` to avoid invoking the listener while `ConnectionManager` holds `conn_ptr_mutex_`).
- **MetadataRole**: `InboxSlot` has no `take_if`, so the deadline gate that used to run under the shadow slot's mutex is split in two: `take()` unconditionally moves any pending delta into a main-thread-only `held_delta` (folding it into an already-held delta), then the server-clock deadline is evaluated with no lock held, applying deltas and firing `on_metadata()` once the `timestamp` is reached (or immediately if there is no active connection). A future-dated `held_delta` persists across ticks with no topic bit set, which is why `needs_drain()` ORs in `held_delta.has_value()` alongside the `INBOX_TOPIC_METADATA` bit test: the deadline sets no inbox bit, so without that term a bit-gated tick would strand the delta until an unrelated new delta happened to re-set the bit, silently starving deadline-based delivery. The clear arrives separately as a `METADATA_CLEARED` ring event (`handle_cleared_event()` fires `on_metadata_clear()`), deferred from `cleanup()` for the same `conn_ptr_mutex_` reason.
- **ColorRole**: Same structure as MetadataRole: `take()` into `held_delta`, a lock-free server-clock deadline gate firing `on_color()`, and a `COLOR_CLEARED` ring event driving `on_color_clear()`.
- **ArtworkRole**: Stream end/clear lifecycle is handled earlier in the tick by `handle_stream_ring_event()` (dispatched from the ring drain, before this call), which clears `held_display_mask`/`display_slot` and fires `on_image_clear()` for each configured slot - preserving the "lifecycle before display" ordering the old single-function drain guaranteed. `drain_events()` itself folds any taken `display_slot` update into the main-thread-only `held_display_*` state (latest-wins per slot), then sweeps the held slots and fires `on_image_display(slot)` for any whose timestamp is due on the synced client clock (or immediately if there is no active connection). Per-slot epochs drop a held display whose stream was replaced after the decode hand-off. `needs_drain()` ORs a nonzero `held_display_mask` into the `INBOX_TOPIC_ARTWORK_DISPLAY` bit test (the same carry-over pattern the metadata role uses for `held_delta`) so held displays keep getting a drain every tick until their deadline fires, even though the deadline sets no inbox bit; `on_image_decode` still happens on the dedicated artwork decode thread.
- **ArtworkRole**: Stream end/clear lifecycle is handled earlier in the tick by `handle_stream_ring_event()` (dispatched from the ring drain, before this call), which clears `held_display_mask`/`display_slot` and fires `on_image_clear()` for each configured slot - preserving the "lifecycle before display" ordering the old single-function drain guaranteed. `drain_events()` itself folds any taken `display_slot` update into the main-thread-only `held_display_*` state (latest-wins per slot), then sweeps the held slots and fires `on_image_display(slot, lateness_ms)` for any whose timestamp is due on the synced client clock (or immediately if there is no active connection). The deadline is computed by the pure `display_overdue_us()` helper, which applies the slot's `display_offset_ms` shift (positive fires early) and returns the overdue microseconds; `display_lateness_ms()` maps that to the `lateness_ms` argument, reserving `0` for the no-connection case (a connected on-time display is floored to 1 ms so it never collides with that sentinel). Per-slot epochs drop a held display whose stream was replaced after the decode hand-off. `needs_drain()` ORs a nonzero `held_display_mask` into the `INBOX_TOPIC_ARTWORK_DISPLAY` bit test (the same carry-over pattern the metadata role uses for `held_delta`) so held displays keep getting a drain every tick until their deadline fires, even though the deadline sets no inbox bit; `on_image_decode` still happens on the dedicated artwork decode thread.
- **Ack gate (`require_frame_done`)**: A slot can opt into per-slot back-pressure. Each `SlotBuffer` carries a `SlotAckState` (`IDLE` -> `DECODE_DELIVERED` once `on_image_decode()` fires -> `PRESENTED` once `on_image_display()`/`on_image_clear()` fires), all guarded by `slot_mutex`. While a gated slot is not `IDLE`, the decode thread (`process_notification()`) does not decode a newer notification; it *parks* it latest-wins in `SlotBuffer::parked` (`has_parked`) instead of decoding concurrently with the un-acked delivery. `ArtworkRole::frame_done(slot)` (main loop) returns the gate to `IDLE` and, if a notification is parked, calls `wake_drain_thread()` -- a sentinel `ARTWORK_RECHECK_SLOT` notification that unblocks the decode thread's `notify_queue.receive()` so it re-runs the top-of-loop parked-slot sweep (a dropped wake is covered by the `DRAIN_RECEIVE_TIMEOUT_MS` fallback). The parked notification is re-validated on replay, so a since-stale generation/epoch is simply skipped. A clear counts as a delivery: `handle_stream_ring_event()` drops any parked notification and forces gated slots to `PRESENTED`, so exactly one `frame_done()` is owed after it. A stream restart releases only `DECODE_DELIVERED` slots (their display can no longer fire); `PRESENTED` stays armed because the consumer may still be mid-fade on the prior stream's last delivery. There is no timeout.
- **VisualizerRole**: Has no `drain_events()`. STREAM_START/END/CLEAR are dispatched entirely from `handle_stream_ring_event()` (from the ring drain): STREAM_START `take()`s the config from `config_slot` and fires `on_visualizer_stream_start()`; STREAM_END/CLEAR fire `on_visualizer_stream_end()`/`on_visualizer_stream_clear()`.

## Sync Task State Machine
Expand Down
57 changes: 50 additions & 7 deletions include/sendspin/artwork_role.h
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ class SendspinClient;
/// THREAD SAFETY: on_image_decode() fires on a dedicated decode thread and must be
/// thread-safe with respect to the other callbacks. on_image_display() and on_image_clear()
/// fire on the main loop thread.
///
/// ACK GATE (opt-in per slot via ImageSlotPreference::require_frame_done): a "delivery" is
/// either a frame (on_image_decode() followed later by on_image_display()) or a clear
/// (on_image_clear()). For an ack-enabled slot, at most one un-acked delivery is ever in flight;
/// the newest payload that arrives while a delivery is un-acked is buffered latest-wins and
/// delivered only after the consumer calls ArtworkRole::frame_done(slot). A clear supersedes any
/// un-acked frame for that slot -- exactly one ack is owed, and it is for the clear. A stream
/// restart automatically releases a frame that was decoded but never displayed (its display can
/// no longer fire), but a delivery that already reached on_image_display()/on_image_clear() stays
/// gated until frame_done() is called; there is no timeout.
class ArtworkRoleListener {
public:
virtual ~ArtworkRoleListener() = default;
Expand All @@ -50,11 +60,20 @@ class ArtworkRoleListener {
/// @brief Called on the main loop thread at the correct timestamp when the decoded image
/// should be displayed
///
/// Fires after on_image_decode() once the server timestamp is reached. If a newer frame for
/// the same slot finishes decoding before the pending display fires, the older pending
/// display is superseded and only the newer one is delivered.
/// Fires after on_image_decode() once the server timestamp is reached. The deadline can be
/// shifted per slot via ImageSlotPreference::display_offset_ms (positive fires early, e.g.
/// to start a cross-fade before the track boundary). If a newer frame for the same slot
/// finishes decoding before the pending display fires, the older pending display is
/// superseded and only the newer one is delivered.
/// @param slot The artwork slot index.
virtual void on_image_display(uint8_t /*slot*/) {}
/// @param lateness_ms How far past the (offset-shifted) deadline this display fired. Displays
/// are best-effort: an image that arrives or decodes after its deadline fires as soon as it
/// is ready, and lateness_ms reports the slip so a consumer can compensate (e.g. shorten a
/// cross-fade by the lateness so it still ends on schedule, or snap instantly on a huge
/// value). On-time displays report a few milliseconds of main-loop polling granularity, never
/// exactly 0, so treat small values as on time. Reports 0 when there is no connection, since
/// no deadline exists.
virtual void on_image_display(uint8_t /*slot*/, uint32_t /*lateness_ms*/) {}

/// @brief Called on the main loop thread when artwork should be cleared for a slot
///
Expand All @@ -72,6 +91,10 @@ class ArtworkRoleListener {
* loop thread, with on_image_display() scheduled to the server timestamp. Supports multiple
* image slots with configurable format and resolution preferences.
*
* A slot may opt into a back-pressure gate via ImageSlotPreference::require_frame_done: see
* the ArtworkRoleListener class comment for the ack contract. Call frame_done() once the
* consumer has finished presenting a delivery for such a slot.
*
* Usage:
* 1. Implement ArtworkRoleListener with on_image_decode() and on_image_display()
* 2. Build an ArtworkRoleConfig with the desired slot/format/resolution preferences
Expand All @@ -84,19 +107,29 @@ class ArtworkRoleListener {
* SendspinImageFormat format) override {
* decoded_images[slot] = decode(data, length, format);
* }
* void on_image_display(uint8_t slot) override {
* display.show_image(slot, decoded_images[slot]);
* void on_image_display(uint8_t slot, uint32_t lateness_ms) override {
* // Slot 0 has require_frame_done set, so this starts a cross-fade; frame_done() is
* // called once the fade finishes instead of immediately. Shortening the fade by the
* // lateness keeps it ending on schedule even when the image arrived late.
* 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);
* }
* void on_fade_complete(uint8_t slot) {
* artwork_role->frame_done(slot);
* }
*
* ArtworkRole* artwork_role{nullptr};
* };
*
* MyArtworkListener listener;
* ArtworkRoleConfig config;
* config.preferred_formats = {{SendspinImageSource::ALBUM,
* SendspinImageFormat::JPEG, 240, 240}};
* SendspinImageFormat::JPEG, 240, 240, true}};
* auto& artwork = client.add_artwork(config);
* listener.artwork_role = &artwork;
* artwork.set_listener(&listener);
* @endcode
*/
Expand All @@ -114,6 +147,16 @@ class ArtworkRole {
/// @param listener Pointer to the listener implementation; must outlive this role
void set_listener(ArtworkRoleListener* listener);

/// @brief Acknowledges the most recent delivery for an ack-gated slot, releasing the gate
///
/// Call from the main loop thread after finishing presentation of the most recent delivery
/// (frame or clear) for a slot with ImageSlotPreference::require_frame_done set, e.g. once a
/// cross-fade animation completes. Safe no-op if the slot has nothing un-acked (including
/// slots where require_frame_done is false). Also safe to call from inside
/// on_image_display() or on_image_clear() for instant (non-animated) presentation.
/// @param slot The artwork slot index to acknowledge.
void frame_done(uint8_t slot);

private:
std::unique_ptr<Impl> impl_;
};
Expand Down
17 changes: 17 additions & 0 deletions include/sendspin/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,23 @@ struct ImageSlotPreference {
SendspinImageFormat format{};
uint16_t width{};
uint16_t height{};

/// @brief Opt-in per-slot back-pressure gate. When true, the role delivers at most one
/// un-acked "delivery" at a time for this slot: a delivery is either a frame
/// (on_image_decode() followed by on_image_display()) or a clear (on_image_clear()). While a
/// delivery is un-acked, any newer payload that arrives is buffered latest-wins and only
/// delivered once the consumer calls ArtworkRole::frame_done(slot) from the main loop (e.g.
/// after a cross-fade animation completes). Defaults to false, which preserves today's
/// behavior of decoding and displaying every frame as it arrives.
bool require_frame_done{false};

/// @brief Fires on_image_display() this many milliseconds before the server's display
/// timestamp (negative delays it). Lets a cross-fade straddle the track boundary: with a
/// 2 s fade, an offset of 1000 starts the fade 1 s before the boundary so the incoming image
/// is fully shown 1 s after it. Positive-equals-earlier mirrors
/// PlayerRoleConfig::fixed_delay_us. Best-effort: an image that arrives or decodes after the
/// offset deadline fires as soon as it is ready, same as any past-timestamp display.
int32_t display_offset_ms{0};
};

/// @brief Configuration for the artwork role
Expand Down
Loading
Loading