Sendspin

Sendspin is a multi-room music experience protocol. The goal of the protocol is to orchestrate all devices that make up the music listening experience. This includes outputting audio on multiple speakers simultaneously, screens and lights visualizing the audio or album art, and wall tablets providing media controls.

Definitions

Role Versioning

Roles define what capabilities and responsibilities a client has. All roles use explicit versioning with the @ character: <role>@<version> (e.g., player@v1, controller@v1).

This specification defines the following roles: player, controller, metadata, artwork, visualizer. All servers must implement all versions of these roles described in this specification.

All role names and versions not starting with _ are reserved for future revisions of this specification.

Priority and Activation

Clients list roles in supported_roles in priority order (most preferred first). If a client supports multiple versions of a role, all should be listed: ["player@v2", "player@v1"].

The server activates one version per role family (e.g., one player@vN, one controller@vN)—the first match it implements from the client's list. The server reports activated roles in active_roles.

Message object keys (e.g., player?, controller?) use unversioned role names. The server determines the appropriate version from the client's active_roles.

Detecting Outdated Servers

Servers should track when clients request roles or role versions they don't implement (excluding those starting with _). This indicates the client supports a newer version of the specification and the server needs to be updated.

Application-Specific Roles

Custom roles outside the specification start with _ (e.g., _myapp_controller, _custom_display). Application-specific roles can also be versioned: _myapp_visualizer@v2.

Establishing a Connection

Sendspin has two standard ways to establish connections: Server and Client initiated. Server Initiated connections are recommended as they provide standardized multi-server behavior, but require mDNS which may not be available in all environments.

Sendspin Servers must support both methods described below.

Server Initiated Connections

Clients announce their presence via mDNS using:

The server discovers available clients through mDNS and connects to each client via WebSocket using the advertised address and path.

Note: Do not manually connect to servers if you are advertising _sendspin._tcp.

Multiple Servers

In environments with multiple Sendspin servers, servers may need to reconnect to clients when starting playback to reclaim them. The server/hello message includes a connection_reason field indicating whether the server is connecting for general availability ('discovery') or for active/upcoming playback ('playback').

Clients can only be connected to one server at a time. Clients must persistently store the server_id of the server that most recently had playback_state: 'playing' (the "last played server").

When a second server connects, clients must:

  1. Accept incoming connections: Complete the handshake (send client/hello, receive server/hello) with the new server before making any decisions.

  2. Decide which server to keep:

    • If the new server's connection_reason is 'playback' → switch to new server
    • If the new server's connection_reason is 'discovery' and the existing server connected with 'playback' → keep existing server
    • If both servers have connection_reason: 'discovery':
      • Prefer the server matching the stored last played server
      • If neither matches (or no history), keep the existing server
  3. Disconnect: Send client/goodbye with reason 'another_server' to the server being disconnected, then close the connection.

Client Initiated Connections

If clients prefer to initiate the connection instead of waiting for the server to connect, the server must be discoverable via mDNS using:

Clients discover the server through mDNS and initiate a WebSocket connection using the advertised address and path.

Note: Do not advertise _sendspin._tcp if the client plans to initiate the connection.

Multiple Servers

Unlike server-initiated connections, servers cannot reclaim clients by reconnecting. How clients handle multiple discovered servers, server selection, and switching is implementation-defined.

Note: After this point, Sendspin works independently of how the connection was established. The Sendspin client is always the consumer of data like audio or metadata, regardless of who initiated the connection.

While custom connection methods are possible for specialized use cases (like remotely accessible web-browsers, mobile apps), most clients should use one of the two standardized methods above if possible.

Communication

Once the connection is established, Client and Server are going to talk.

The first message must always be a client/hello message from the client to the server. Once the server receives this message, it responds with a server/hello message. Before this handshake is complete, no other messages should be sent.

WebSocket text messages are used to send JSON payloads.

Note: In field definitions, ? indicates an optional field (e.g., field?: type means the field may be omitted).

All messages have a type field identifying the message and a payload object containing message-specific data. The payload structure varies by message type and is detailed in each message section below.

Message format example:

{
  "type": "stream/start",
  "payload": {
    "player": {
      "codec": "opus",
      "sample_rate": 48000,
      "channels": 2,
      "bit_depth": 16
    },
    "artwork": {
      "channels": [
        {
          "source": "album",
          "format": "jpeg",
          "width": 800,
          "height": 800
        }
      ]
    }
  }
}

WebSocket binary messages are used to send audio chunks, media art, and visualization data. The first byte is a uint8 representing the message type.

Binary Message ID Structure

Binary message IDs typically use bits 7-2 for role type and bits 1-0 for message slot, allocating 4 IDs per role. Roles with expanded allocations use bits 2-0 for message slot (8 IDs).

Role assignments:

Message slots:

Roles with expanded allocations have slots 0-7.

Note: Role versions share the same binary message IDs (e.g., player@v1 and player@v2 both use IDs 4-7).

Clock Synchronization

Clients continuously send client/time messages to maintain an accurate offset from the server's clock. The frequency of these messages is determined by the client based on network conditions and clock stability.

Binary audio messages contain timestamps in the server's time domain indicating when the audio should be played. Clients use their computed offset to translate server timestamps to their local clock for synchronized playback.

Note: For microsecond-level synchronization precision, consider using a two-dimensional Kalman filter to track both clock offset and drift. See the time-filter repository for a C++ implementation and aiosendspin for a Python implementation.

Playback Synchronization

sequenceDiagram
    participant Client
    participant Server

    Note over Client,Server: WebSocket connection established

    Note over Client,Server: Text messages = JSON payloads, Binary messages = Audio/Art/Visualization

    Client->>Server: client/hello (roles and capabilities)
    Server->>Client: server/hello (server info, connection_reason)

    alt Player role
        Client->>Server: client/state (player: volume, muted, state)
    end

    loop Continuous clock sync
        Client->>Server: client/time (client clock)
        Server->>Client: server/time (timing + offset info)
    end

    alt Stream starts
        Server->>Client: stream/start (codec, format details)
    end

    Server->>Client: group/update (playback_state, group_id, group_name)
    Server->>Client: server/state (metadata, controller)

    loop During playback
        alt Player role
            Server->>Client: binary Type 4 (audio chunks with timestamps)
        end
        alt Artwork role
            Server->>Client: binary Types 8-11 (artwork channels 0-3)
        end
        alt Visualizer role
            Server->>Client: binary Type 16 (visualization data)
        end
    end

    alt Player requests format change
        Client->>Server: stream/request-format (codec, sample_rate, etc)
        Server->>Client: stream/start (player: new format)
    end

    alt Seek operation
        Server->>Client: stream/clear (roles: [player, visualizer])
    end

    alt Controller role
        Client->>Server: client/command (controller: play/pause/volume/switch/etc)
    end

    alt Player role state changes
        Client->>Server: client/state (player state changes)
    end

    alt Server commands player
        Server->>Client: server/command (player: volume, mute)
    end

    Server->>Client: stream/end (ends all role streams)
    alt Player role
        Client->>Server: client/state (player idle state)
    end

    alt Graceful disconnect
        Client->>Server: client/goodbye (reason)
        Note over Client,Server: Server initiates disconnect
    end

Core messages

This section describes the fundamental messages that establish communication between clients and the server. These messages handle initial handshakes, ongoing clock synchronization, stream lifecycle management, and role-based state updates and commands.

Every Sendspin client and server must implement all messages in this section regardless of their specific roles. Role-specific object details are documented in their respective role sections and need to be implemented only if the client supports that role.

Client → Server: client/hello

First message sent by the client after establishing the WebSocket connection. Contains information about the client's capabilities and roles. This message will be followed by a server/hello message from the server.

Players that can output audio should have the role player.

Note: Each role version may have its own support object (e.g., player@v1_support, player@v2_support). Application-specific roles or role versions follow the same pattern (e.g., _myapp_display@v1_support, player@_experimental_support).

Client → Server: client/time

Sends current internal clock timestamp (in microseconds) to the server. Once received, the server responds with a server/time message containing timing information to establish clock offsets.

Server → Client: server/hello

Response to the client/hello message with information about the server.

Only after receiving this message should the client send any other messages (including client/time and the initial client/state message if the client has roles that require state updates).

Note: Servers will always activate the client's preferred version of each role. Checking active_roles is only necessary to detect outdated servers or confirm activation of application-specific roles.

Server → Client: server/time

Response to the client/time message with timestamps to establish clock offsets.

For synchronization, all timing is relative to the server's monotonic clock. These timestamps have microsecond precision and are not necessarily based on epoch time.

Client → Server: client/state

Client sends state updates to the server. Contains role-specific state objects based on the client's supported roles.

Must be sent immediately after receiving server/hello for roles that report state (such as player), and whenever any state changes thereafter.

For the initial message, include all state fields. For subsequent updates, only include fields that have changed. The server will merge these updates into existing state.

Application-specific roles may also include objects in this message (keys starting with _).

Client → Server: client/command

Client sends commands to the server. Contains command objects based on the client's supported roles.

Application-specific roles may also include objects in this message (keys starting with _).

Server → Client: server/state

Server sends state updates to the client. Contains role-specific state objects.

Only include fields that have changed. The client will merge these updates into existing state. Fields set to null should be cleared from the client's state.

Application-specific roles may also include objects in this message (keys starting with _).

Server → Client: server/command

Server sends commands to the client. Contains role-specific command objects.

Application-specific roles may also include objects in this message (keys starting with _).

Server → Client: stream/start

Starts a stream for one or more roles. If sent for a role that already has an active stream, updates the stream configuration without clearing buffers.

Application-specific roles may also include objects in this message (keys starting with _).

Server → Client: stream/clear

Instructs clients to clear buffers without ending the stream. Used for seek operations.

Application-specific roles may also be included in this array (names starting with _).

Client → Server: stream/request-format

Request different stream format (upgrade or downgrade). Available for clients with the player or artwork role.

Application-specific roles may also include objects in this message (keys starting with _).

Response: stream/start for the requested role(s) with the new format.

Note: Clients should use this message to adapt to changing network conditions, CPU constraints, or display requirements. The server maintains separate encoding for each client, allowing heterogeneous device capabilities within the same group.

Server → Client: stream/end

Ends the stream for one or more roles. When received, clients should stop output and clear buffers for the specified roles.

Application-specific roles may also be included in this array (names starting with _).

Server → Client: group/update

State update of the group this client is part of.

Contains delta updates with only the changed fields. The client should merge these updates into existing state. Fields set to null should be cleared from the client's state.

Client → Server: client/goodbye

Sent by the client before gracefully closing the connection. This allows the client to inform the server why it is disconnecting.

Upon receiving this message, the server should initiate the disconnect.

Note: Clients may close the connection without sending this message (e.g., crash, network loss), or immediately after sending client/goodbye without waiting for the server to disconnect. When a client disconnects without sending client/goodbye, servers should assume the disconnect reason is restart and attempt to auto-reconnect.

Player messages

This section describes messages specific to clients with the player role, which handle audio output and synchronized playback. Player clients receive timestamped audio data, manage their own volume and mute state, and can request different audio formats based on their capabilities and current conditions.

Note: Volume values (0-100) represent perceived loudness, not linear amplitude (e.g., volume 50 should be perceived as half as loud as volume 100). Players must convert these values to appropriate amplitude for their audio hardware.

Client → Server: client/hello player@v1 support object

The player@v1_support object in client/hello has this structure:

Note: Servers must support all audio codecs: 'opus', 'flac', and 'pcm'.

Client → Server: client/state player object

The player object in client/state has this structure:

Informs the server of player state changes. Only for clients with the player role.

State updates must be sent whenever any state changes, including when the volume was changed through a server/command or via device controls.

Client → Server: stream/request-format player object

The player object in stream/request-format has this structure:

Response: stream/start with the new format.

Note: Clients should use this message to adapt to changing network conditions or CPU constraints. The server maintains separate encoding for each client, allowing heterogeneous device capabilities within the same group.

Server → Client: server/command player object

The player object in server/command has this structure:

Request the player to perform an action, e.g., change volume or mute state.

Server → Client: stream/start player object

The player object in stream/start has this structure:

Server → Client: stream/clear player

When stream/clear includes the player role, clients should clear all buffered audio chunks and continue with chunks received after this message.

Server → Client: Audio Chunks (Binary)

Binary messages should be rejected if there is no active stream.

The timestamp indicates when the first audio sample in this chunk should be output. Clients must translate this server timestamp to their local clock using the offset computed from clock synchronization. Clients should compensate for any known processing delays (e.g., DAC latency, audio buffer delays, amplifier delays) by accounting for these delays when submitting audio to the hardware.

Controller messages

This section describes messages specific to clients with the controller role, which enables the client to control the Sendspin group this client is part of, and switch between groups.

Every client which lists the controller role in the supported_roles of the client/hello message needs to implement all messages in this section.

Client → Server: client/command controller object

The controller object in client/command has this structure:

Control the group that's playing and switch groups. Only valid from clients with the controller role.

Command behaviour

Setting group volume: When setting group volume via the 'volume' command, the server applies the following algorithm to preserve relative volume levels while achieving the requested volume as closely as player boundaries allow:

  1. Calculate the delta: delta = requested_volume - current_group_volume (where current group volume is the average of all player volumes)
  2. Apply the delta to each player's volume
  3. Clamp any player volumes that exceed boundaries (0-100%)
  4. If any players were clamped:
    • Calculate the lost delta: sum of (proposed_volume - clamped_volume) for all clamped players
    • Divide the lost delta equally among non-clamped players
    • Repeat steps 1-4 until either:
      • All delta has been successfully applied, or
      • All players are clamped at their volume boundaries

This ensures that when setting group volume to 100%, all players will reach 100% if possible, and the final group volume matches the requested volume as closely as player boundaries allow.

Setting group mute: When setting group mute via the 'mute' command, the server applies the mute state to all players in the group.

Switch command cycle

For clients with the player role, the cycle includes:

  1. Multi-client groups that are currently playing
  2. Single-client groups (other players playing alone)
  3. A solo group containing only this client

For clients without the player role, the cycle includes:

  1. Multi-client groups that are currently playing
  2. Single-client groups (other players playing alone)

Server → Client: server/state controller object

The controller object in server/state has this structure:

Reading group volume: Group volume is calculated as the average of all player volumes in the group.

Reading group mute: Group mute is true only when all players in the group are muted. If some players are muted and others are not, group mute is false.

Metadata messages

This section describes messages specific to clients with the metadata role, which handle display of track information and playback progress. Metadata clients receive state updates with track details.

Server → Client: server/state metadata object

The metadata object in server/state has this structure:

Calculating current track position

Clients can calculate the current track position at any time using the timestamp and progress values from the last metadata message that included the progress object:

calculated_progress = metadata.progress.track_progress + (current_time - metadata.timestamp) * metadata.progress.playback_speed / 1000000

if metadata.progress.track_duration != 0:
    current_track_progress_ms = max(min(calculated_progress, metadata.progress.track_duration), 0)
else:
    current_track_progress_ms = max(calculated_progress, 0)

Artwork messages

This section describes messages specific to clients with the artwork role, which handle display of artwork images. Artwork clients receive images in their preferred format and resolution.

Channels: Artwork clients can support 1-4 independent channels, allowing them to display multiple related images. For example, a device could display album artwork on one channel while simultaneously showing artist photos or background images on other channels. Each channel operates independently with its own format, resolution, and source type (album or artist artwork).

Client → Server: client/hello artwork@v1 support object

The artwork@v1_support object in client/hello has this structure:

Note: The server will scale images to fit within the specified dimensions while preserving aspect ratio. Clients can support 1-4 independent artwork channels depending on their display capabilities. The channel number is determined by array position: channels[0] is channel 0 (binary message type 4), channels[1] is channel 1 (binary message type 5), etc.

None source: If a channel has source set to none, the server will not send any artwork data for that channel. This allows clients to disable and enable specific channels on the fly through stream/request-format without needing to re-establish the WebSocket connection (useful for dynamic display layouts).

Note: Servers must support all image formats: 'jpeg', 'png', and 'bmp'.

Client → Server: stream/request-format artwork object

The artwork object in stream/request-format has this structure:

Request the server to change the artwork format for a specific channel. The client can send multiple stream/request-format messages to change formats on different channels.

After receiving this message, the server responds with stream/start for the artwork role with the new format, followed by immediate artwork updates through binary messages.

Server → Client: stream/start artwork object

The artwork object in stream/start has this structure:

Server → Client: Artwork (Binary)

Binary messages should be rejected if there is no active stream.

The message type determines which artwork channel this image is for:

The timestamp indicates when this artwork should be displayed. Clients must translate this server timestamp to their local clock using the offset computed from clock synchronization.

Clearing artwork: To clear the currently displayed artwork on a specific channel, the server sends an empty binary message (only the message type byte and timestamp, with no image data) for that channel.

Visualizer messages

This section describes messages specific to clients with the visualizer role, which create visual representations of the audio being played. Visualizer clients receive audio analysis data like FFT information that corresponds to the current audio timeline.

Client → Server: client/hello visualizer@v1 support object

The visualizer@v1_support object in client/hello has this structure:

Server → Client: stream/start visualizer object

The visualizer object in stream/start has this structure:

Server → Client: stream/clear visualizer

When stream/clear includes the visualizer role, clients should clear all buffered visualization data and continue with data received after this message.

Server → Client: Visualization Data (Binary)

Binary messages should be rejected if there is no active stream.

The timestamp indicates when this visualization data should be displayed, corresponding to the audio timeline. Clients must translate this server timestamp to their local clock using the offset computed from clock synchronization.