From 5333d43ec016a8b0a3b3190165787118984a6ab1 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Wed, 1 Jul 2026 22:07:58 +0200 Subject: [PATCH 1/7] Resolve client-facing spec gaps --- README.md | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index c0a6fd6..ab74e97 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Servers should track when clients request roles or role versions they don't impl ### 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`. +Custom roles outside the specification start with `_` (e.g., `_myapp_controller`, `_custom_display`). Application-specific roles can also be versioned: `_myapp_visualizer@v2`. To avoid collisions between independent vendors, custom role names SHOULD include a vendor-specific prefix (e.g., `_vendorname_role`). ## Establishing a Connection @@ -56,7 +56,7 @@ Sendspin Servers must support both methods described below. Clients announce their presence via mDNS using: - Service type: `_sendspin._tcp.local.` - Port: The port the Sendspin client is listening on (recommended: `8928`) -- TXT record: `path` key specifying the WebSocket endpoint (recommended: `/sendspin`) +- TXT record: `path` key specifying the WebSocket endpoint, REQUIRED (recommended value: `/sendspin`) - TXT record: `name` key specifying the friendly name of the player (optional) The server discovers available clients through mDNS and connects to each client via WebSocket using the advertised address and path. @@ -89,7 +89,7 @@ A displaced connection receives [`client/goodbye`](#client--server-clientgoodbye If clients prefer to initiate the connection instead of waiting for the server to connect, the server must be discoverable via mDNS using: - Service type: `_sendspin-server._tcp.local.` - Port: The port the Sendspin server is listening on (recommended: `8927`) -- TXT record: `path` key specifying the WebSocket endpoint (recommended: `/sendspin`) +- TXT record: `path` key specifying the WebSocket endpoint, REQUIRED (recommended value: `/sendspin`) - TXT record: `name` key specifying the friendly name of the server (optional) Clients discover the server through mDNS and initiate a WebSocket connection using the advertised address and path. @@ -565,6 +565,8 @@ The initial message MUST include all state fields. In subsequent messages, the c When a client sets `state: 'external_source'`, it indicates the client's output is in use by an external system (e.g., a different audio source, HDMI input, or local media playback) and is not currently participating in Sendspin playback with this server. +A client SHOULD report `external_source` only while it will not yield its output to Sendspin. If the current external activity can be interrupted by Sendspin playback, the client SHOULD stay `synchronized` (letting the server take over at any time) rather than reporting `external_source`, and SHOULD return to `synchronized` as soon as it is again willing to be taken over. + #### Server behavior when `state` changes to `'external_source'`: If the client is in a multi-client group: @@ -616,6 +618,8 @@ Starts a stream for one or more roles. If sent for a role that already has an ac [Application-specific roles](#application-specific-roles) may also include objects in this message (keys starting with `_`). +The server MUST NOT send `stream/start` to a client that is not in the [`synchronized`](#client--server-clientstate) state (e.g. a client in [`external_source`](#external-source-handling)). + ### Server → Client: `stream/clear` Instructs clients to clear buffers without ending the stream. Used for seek operations and track jumps (switching to a different track without stopping the stream). @@ -683,7 +687,7 @@ Sent by the client before gracefully closing the connection. This allows the cli Upon receiving this message, the server should initiate the disconnect. - `reason`: 'another_server' | 'shutdown' | 'restart' | 'user_request' | 'unauthorized' | 'pairing_required' | 'concurrent_attempt' | 'unpaired' - - `another_server` - client is switching to a different Sendspin server. Server should not auto-reconnect but should show the client as available for future playback + - `another_server` - client is switching to a different Sendspin server. A client that leaves one server for another MUST send this reason to the server it is leaving. Server should not auto-reconnect but should show the client as available for future playback - `shutdown` - client is shutting down. Server should not auto-reconnect - `restart` - client is restarting and will reconnect. Server should auto-reconnect - `user_request` - user explicitly requested to disconnect from this server. Server should not auto-reconnect @@ -692,6 +696,8 @@ Upon receiving this message, the server should initiate the disconnect. - `concurrent_attempt` - the client refused the connection because a higher-or-equal-priority connection is already active (e.g., one with `'management'` in its activity set, or a pairing handshake when the incoming connection is also pairing). Server may retry later - `unpaired` - the client has processed [`server/unpair`](#server--client-serverunpair) from this server. Server should not auto-reconnect +**Note:** When the device is powering off or otherwise not coming back and no more specific reason applies, clients SHOULD send `shutdown`. + **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`: - On a connection whose `activities` are empty, or include `'playback'`, servers should assume the disconnect reason is `restart` and attempt to auto-reconnect. @@ -714,6 +720,8 @@ Static pairing methods (Pairing PSK, static PIN) do not take over the device's o Clients with a usable out-channel (display, speaker, etc.) SHOULD implement `dynamic_pin` rather than `static_pin`. `static_pin` is intended only for devices that genuinely cannot emit a per-session value. +The initial static PIN MUST be device-specific (e.g., randomly generated and printed on the device, or otherwise factory-provisioned) and MUST NOT be a fixed default shared across devices; a shared default would let anyone pair with any such device. + ### Entering and leaving pairing Pairing and playback are mutually exclusive on a connection. When a server moves an established connection into pairing it first quiesces it exactly as an [`external_source`](#external-source-handling) transition does, and sends the pairing [`server/activate`](#server--client-serveractivate) with empty `active_roles`. @@ -1166,6 +1174,8 @@ State updates must be sent whenever any state changes, including when the volume **Static delay:** The default is 0, meaning audio exits the device's audio port at the timestamp. `static_delay_ms` compensates for additional delay beyond the port (external speakers, amplifiers). Negative values are not supported and should never be required for any compliant implementation. Clients must persist `static_delay_ms` locally across reboots and server reconnections. Clients may update `static_delay_ms` and `supported_commands` when audio output changes (e.g., external speaker connected), persisting separate delays per output. +**Volume and mute:** Persisting `volume` and `muted` across reboots is RECOMMENDED for players. A server MUST NOT assume these values are unchanged after a reconnect. + **Timing parameters:** Clients may update `required_lead_time_ms` and `min_buffer_ms` at any time (e.g., after empirically measuring lead time post-warmup, or when network conditions change). A [`stream/clear`](#server--client-streamclear) (seek or track jump) restarts on an already-running pipeline, so it often needs less warmup than a [`stream/start`](#server--client-streamstart) that begins a new stream. A client MAY lower its reported `required_lead_time_ms` while a stream is running and raise it again before the next one begins. Servers must factor in updated values for subsequent playback timing. Clients should debounce updates locally, reporting changes only after a shift in conditions appears sustained, not on transient fluctuations. ### Client → Server: `stream/request-format` player object @@ -1203,7 +1213,7 @@ The `player` object in [`stream/start`](#server--client-streamstart) has this st - `sample_rate`: integer - sample rate to be used - `channels`: integer - channels to be used - `bit_depth`: integer - bit depth to be used - - `codec_header?`: string - Base64 encoded codec header (if necessary; e.g., FLAC) + - `codec_header?`: string - codec header encoded as standard Base64, if necessary (e.g., FLAC) ### Server → Client: `stream/clear` player @@ -1235,14 +1245,11 @@ A source client uses the same [clock synchronization](#clock-synchronization) me The `source@v1_support` object in [`client/hello`](#client--server-clienthello) has this structure: - `source@v1_support`: object - - `supported_formats`: object[] - list of supported capture/encode formats in priority order (first is preferred) - - `codec`: 'opus' | 'flac' | 'pcm' - codec identifier - - `channels`: integer - number of channels (e.g., 1 = mono, 2 = stereo) - - `sample_rate`: integer - sample rate in Hz (e.g., 44100, 48000) - - `bit_depth`: integer - bit depth (e.g., 16, 24) - `features?`: object - optional feature hints - `line_sense?`: boolean - true if source reports `signal` +A source declares its capture format per stream in `client_stream/start`; the server accepts whatever the source produces, so no format is advertised here. + **Note:** Servers MUST support all audio codecs: 'opus', 'flac', and 'pcm'. ### Client → Server: `client/state` source object @@ -1282,7 +1289,7 @@ The `client_stream/start` message announces the active input stream format and p - `channels`: integer - `sample_rate`: integer - `bit_depth`: integer - - `codec_header?`: string - Base64 encoded codec header (if necessary; e.g., FLAC) + - `codec_header?`: string - codec header encoded as standard Base64, if necessary (e.g., FLAC) ### Client → Server: `client_stream/end` From e00ed7d87cfd0a981fd982c46db4071d088ffabb Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Wed, 1 Jul 2026 22:49:24 +0200 Subject: [PATCH 2/7] Fix remaining `external_source` gaps --- README.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ab74e97..e556cf3 100644 --- a/README.md +++ b/README.md @@ -563,9 +563,19 @@ The initial message MUST include all state fields. In subsequent messages, the c ### External Source Handling -When a client sets `state: 'external_source'`, it indicates the client's output is in use by an external system (e.g., a different audio source, HDMI input, or local media playback) and is not currently participating in Sendspin playback with this server. +A client's output can be taken over by a non-Sendspin activity (playing other media, another protocol, an HDMI input, and so on). How it reports this depends on whether it will still yield its output back to Sendspin on request. -A client SHOULD report `external_source` only while it will not yield its output to Sendspin. If the current external activity can be interrupted by Sendspin playback, the client SHOULD stay `synchronized` (letting the server take over at any time) rather than reporting `external_source`, and SHOULD return to `synchronized` as soon as it is again willing to be taken over. +#### Interruptible activity (client stays available) + +If the external activity can be interrupted by Sendspin playback at any time, the client SHOULD remain `synchronized` so the server can take it over. + +To stop rendering its group's audio while it does its own thing, a client MAY leave its current group by sending a `client/state` with `state: 'external_source'` immediately followed by a `client/state` with `state: 'synchronized'` (the server behavior below then moves it to a solo group and does not rejoin it). This is only needed while the group's `playback_state` is `'playing'`. + +#### Non-interruptible activity (client becomes unavailable) + +When a client sets `state: 'external_source'`, it indicates the client's output is in use by an external system (e.g., a different audio source, HDMI input, or local media playback) and will not participate in Sendspin playback with this server until it returns to `synchronized`. + +A client SHOULD report `external_source` only while it will not yield its output to Sendspin, and SHOULD return to `synchronized` as soon as it is again willing to be taken over. #### Server behavior when `state` changes to `'external_source'`: @@ -578,6 +588,8 @@ If the client is in a multi-client group: If the client is already in a solo group: - Stop playback and send [`stream/end`](#server--client-streamend) for all active streams +When a client returns to `synchronized`, the server MUST NOT auto-rejoin it to its previous group or restart playback; the client remains in the solo group and rejoins only via an explicit [`switch`](#switch-command-cycle). + ### Client → Server: `client/command` Client sends commands to the server. Contains command objects based on the client's supported roles. From 1881c916c19115bcedb130f491264c56aa17e428 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Wed, 1 Jul 2026 22:51:01 +0200 Subject: [PATCH 3/7] Drop redundant line --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index e556cf3..05b3977 100644 --- a/README.md +++ b/README.md @@ -1260,8 +1260,6 @@ The `source@v1_support` object in [`client/hello`](#client--server-clienthello) - `features?`: object - optional feature hints - `line_sense?`: boolean - true if source reports `signal` -A source declares its capture format per stream in `client_stream/start`; the server accepts whatever the source produces, so no format is advertised here. - **Note:** Servers MUST support all audio codecs: 'opus', 'flac', and 'pcm'. ### Client → Server: `client/state` source object From 6317ab30e9eadf54aa9d4b4fb42982b833a69b72 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 2 Jul 2026 14:27:00 +0200 Subject: [PATCH 4/7] Reserve undefined `payload` fields for the spec --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 05b3977..1615d0d 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ Cleartext handshake messages (`client/init`, `server/init`, `noise/handshake`) a 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. -**Forward compatibility.** Clients MUST ignore unrecognized `payload` fields (keys not defined for the message) rather than treating them as an error. +**Forward compatibility.** Clients MUST ignore unrecognized `payload` fields (keys not defined for the message) rather than treating them as an error. Clients MUST NOT send fields the specification does not define for the message, other than the `_`-prefixed [application-specific role](#application-specific-roles) objects a message explicitly permits. Message format example: From 102fb2d0dd002964fc76aeebc6454eaa5d1bfca0 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 2 Jul 2026 14:57:44 +0200 Subject: [PATCH 5/7] Require clients to request only advertised formats --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 1615d0d..b04d183 100644 --- a/README.md +++ b/README.md @@ -1200,6 +1200,8 @@ The `player` object in [`stream/request-format`](#client--server-streamrequest-f - `sample_rate?`: integer - requested sample rate in Hz (e.g., 44100, 48000) - `bit_depth?`: integer - requested bit depth (e.g., 16, 24) +The requested format MUST be one the client listed in its [`supported_formats`](#client--server-clienthello-playerv1-support-object). + Response when a `player` stream is active: [`stream/start`](#server--client-streamstart) 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. From 568ebe113d463f2306163be51698fa1e2e5990f2 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Mon, 6 Jul 2026 17:17:43 +0200 Subject: [PATCH 6/7] Reword external_source interruptible case --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b04d183..633c65a 100644 --- a/README.md +++ b/README.md @@ -569,7 +569,7 @@ A client's output can be taken over by a non-Sendspin activity (playing other me If the external activity can be interrupted by Sendspin playback at any time, the client SHOULD remain `synchronized` so the server can take it over. -To stop rendering its group's audio while it does its own thing, a client MAY leave its current group by sending a `client/state` with `state: 'external_source'` immediately followed by a `client/state` with `state: 'synchronized'` (the server behavior below then moves it to a solo group and does not rejoin it). This is only needed while the group's `playback_state` is `'playing'`. +To stop rendering its group's audio while performing non-Sendspin activity, a client MAY leave its current group by sending a `client/state` with `state: 'external_source'` immediately followed by a `client/state` with `state: 'synchronized'` (the server behavior below then moves it to a solo group and does not rejoin it). This is only needed while the group's `playback_state` is `'playing'`. #### Non-interruptible activity (client becomes unavailable) From 07978bd5a05864ac70e2053ad7e84094e8b31938 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Mon, 6 Jul 2026 17:18:20 +0200 Subject: [PATCH 7/7] Use RFC keywords in `another_server` goodbye reason --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 633c65a..7c8ee17 100644 --- a/README.md +++ b/README.md @@ -699,7 +699,7 @@ Sent by the client before gracefully closing the connection. This allows the cli Upon receiving this message, the server should initiate the disconnect. - `reason`: 'another_server' | 'shutdown' | 'restart' | 'user_request' | 'unauthorized' | 'pairing_required' | 'concurrent_attempt' | 'unpaired' - - `another_server` - client is switching to a different Sendspin server. A client that leaves one server for another MUST send this reason to the server it is leaving. Server should not auto-reconnect but should show the client as available for future playback + - `another_server` - client is switching to a different Sendspin server. A client that leaves one server for another MUST send this reason to the server it is leaving. Server SHOULD NOT auto-reconnect but SHOULD show the client as available for future playback - `shutdown` - client is shutting down. Server should not auto-reconnect - `restart` - client is restarting and will reconnect. Server should auto-reconnect - `user_request` - user explicitly requested to disconnect from this server. Server should not auto-reconnect