Sendspin Spec Compliance Audit - 2026-06-12
Sendspin Spec Compliance Audit - 2026-06-12
Audit of SendSpinDroid against the current Sendspin protocol spec (github.com/Sendspin/spec @ 2026-06-11, “Add visualizer@v1 role”), plus an evaluation of the official JVM library (github.com/Sendspin/sendspin-jvm v0.3.0).
Status update (same day): All P1, P2, and P4 issues plus P3 items 6/7/8/10 are fixed on branch
task/spec-compliance(one commit per issue). The official Sendspin/conformance harness now runs in CI (.github/workflows/conformance.yml); theclient-initiated-pcmscenario passes against aiosendspin (“PCM hashes match exactly”). Remaining open items: P3.9 server-initiated connections (build-vs-library decision) and the optional visualizer/color roles.
Recent spec changes that affect us
| Date | Change | Impact on us |
|---|---|---|
| 2026-06-11 | visualizer@v1 role finalized: binary types 16-20 (loudness, beat, f_peak, spectrum, peak); types 21-23 reserved |
Optional role; we don’t claim it (compliant). Opportunity for UI visualizations |
| 2026-06-10 | pitch visualizer type removed |
n/a |
| 2026-06-01 | required_lead_time_ms and min_buffer_ms added to client/state player object - “always required for players” (#69) |
We don’t send them - now non-compliant |
| 2026-06-01 | mac_address added to device_info (optional) (#87) |
Not feasible on Android 11+ (MAC randomization, getHardwareAddress() returns null for apps targeting API 30+). Skip |
| 2026-04-27 | Repeat/shuffle states moved into server/state controller object (#81) |
We claim controller@v1 but never parse controller state |
| 2026-04-27 | color@v1 role added (#70) |
Optional; not claimed (compliant). Could power UI theming |
| 2026-04-10 | Kalman time filter algorithm now required (#73) | Already compliant (SendspinTimeFilter) |
Issues to fix
P1 - Wire-format bugs
1. client/state: state field is nested in the wrong place.
MessageBuilder.buildPlayerState() (android/shared/src/commonMain/kotlin/com/sendspindroid/sendspin/protocol/message/MessageBuilder.kt:99-112)
sends:
{"payload": {"player": {"state": "synchronized", "volume": 50, "muted": false, "static_delay_ms": 12.5}}}
The spec defines state as a top-level payload field, sibling to player:
{"payload": {"state": "synchronized", "player": {"volume": 50, "muted": false, "static_delay_ms": 12}}}
A strict server sees no operational state from us at all (including our
error/synchronized transitions, which drive server-side awareness of sync
loss). Works today only because Music Assistant’s parser is lenient.
Fix: move state to top level. Keeping a duplicate inside player during a
transition period is harmless if needed for old servers.
2. Missing required_lead_time_ms and min_buffer_ms in client/state player object.
Spec marks both “always required for players” (added 2026-06-01). Servers use
them to compute per-player send-ahead (max(required_lead_time_ms,
min_buffer_ms) + static_delay_ms) - especially important for live streams.
Without them, an updated server has to guess our startup warmup and jitter
buffer needs.
We already have the data to populate these honestly:
required_lead_time_ms: AudioTrack warmup + decoder init + measured DAC latency (we measure this forstatic_delay_msauto-compensation already).min_buffer_ms: derived from our buffer config (35 s normal / 10 s low-memory is the capacity; the minimum we need to absorb jitter is much smaller - start conservative, e.g. 500-1000 ms, then tune). Spec also allows updating them at runtime (debounced) - a later refinement.
3. Invalid client/goodbye reason "network_type_changed".
SendSpin.kt:832 (disconnectForReselection()). Spec enum is
another_server | shutdown | restart | user_request. Our network-reselection
disconnect maps cleanly to restart (client will reconnect; server should
auto-reconnect too). user_request at SendSpin.kt:857 is already valid.
4. static_delay_ms type and range.
Spec: integer, 0-5000, negative not supported. We send a double
(MessageBuilder.kt:107) computed as autoMeasuredDelayMicros +
userSyncOffsetMicros (SendspinTimeFilter.kt:241-242), which can go negative
with a negative user sync offset. Round to Int and clamp to 0-5000 on the wire
(keep applying the real signed value locally - the spec only constrains the
reported field).
P2 - Role compliance
5. controller@v1 is claimed but only partially implemented.
Spec: “Every client which lists the controller role … needs to implement all
messages in this section.” Gaps:
server/statecontroller object is never parsed (nocontrollerhandling inMessageParser.kt):supported_commands, groupvolume,muted,repeat,shuffle. Repeat/shuffle state moved here from metadata on 2026-04-27 - if our UI shows repeat/shuffle from metadata, it’s reading a removed field.- Commands sent are limited to play/pause/next/previous/switch
(
SendSpin.kt:867-871); spec also defines stop, volume, mute, repeat_off, repeat_one, repeat_all, shuffle, unshuffle. - We don’t gate sent commands on the server’s
supported_commands. Options: (a) implement controller state parsing + the missing commands, or (b) stop claimingcontroller@v1if we only need transport controls via another path. (a) is right for us - group volume/mute UI needs it.
P3 - Missing optional features worth adopting
6. external_source client state. New tri-state
(synchronized | error | external_source). Natural Android mapping: when we
lose audio focus to another app (or the user plays local media), report
external_source instead of just going silent; the server then parks us in a
solo group and the switch command prioritizes rejoining our previous group.
Good UX win for a phone client, where focus loss is routine.
7. server/command set_static_delay. We neither declare
supported_commands: ["set_static_delay"] in the client/state player object
nor handle the command (MessageParser.kt:122-148 parses volume/mute only).
Letting the server/UI tune device delay remotely is useful for speaker-pairing
calibration flows.
8. stream/request-format. Not implemented. It’s the only way to change
audio format (e.g. drop opus -> pcm on CPU pressure) or toggle artwork channels
(source: "none") without reconnecting.
9. Server-initiated connections. The spec’s recommended method (client
advertises _sendspin._tcp. on port 8928, runs a WebSocket server; server
connects in). We only do client-initiated (browse _sendspin-server._tcp,
NsdDiscoveryManager.kt:31), which is permitted but second-class: it lacks the
standardized multi-server reclaim/arbitration behavior. Large work item
(embedded WS server + mDNS advertise + multi-server arbitration incl. last
played server persistence + client/goodbye 'another_server'). Note our
remote/proxy connection modes already fall under the spec’s “custom connection
methods” allowance.
10. Metadata progress extrapolation. Spec gives a formula to compute live
track position from timestamp + progress{track_progress, track_duration,
playback_speed}. We report position as-is from the last server/state
(MessageParser.kt:70-119), so the seekbar/MediaSession position only moves
when the server pushes. Implement the formula (clamped per spec) for smooth
position in notification/Auto.
P4 - Minor hygiene
11. device_info.software_version is hardcoded "1.0.0"
(MessageBuilder.kt:43). Should be the real versionName (2.0.0-Beta11).
12. Binary messages are not rejected when no stream is active
(BinaryMessageParser.kt); spec says they “should be rejected”. Low risk -
the buffer state machine mostly handles it - but cheap to add.
13. group/update.playback_state accepts any string; spec enum is
playing | stopped. Keep lenient parsing, but don’t branch on undocumented
values.
14. client/sync_offset (handled at MessageParser.kt:183-190) is not in
the spec - it’s a Music Assistant extension. Keep, but mark it as such in code
comments. Same for the legacy position_ms/duration_ms metadata fallback.
JVM library evaluation: Sendspin/sendspin-jvm v0.3.0
Kotlin/JVM, Apache-2.0 (GitHub shows “Other” only because the LICENSE file
appends the notice block - it is plain Apache 2.0, MIT-app compatible).
Single module sendspin-protocol, ~2,000 lines main source, OkHttp 4.12 +
Java-WebSocket + Moshi + coroutines. No Android dependencies; platform seams
(AudioPlayer, NsdBrowser, NsdRegistrar) are interfaces clearly designed
for an Android consumer (comments reference an AndroidNsdBrowser; default
client name is “SendSpin TV” - it was factored out of a TV app).
What it covers that we lack
- Server-initiated connection mode: embedded WS server on 8928 +
_sendspin._tcp.advertising + full spec multi-server arbitration (playback > discovery, last-played persistence, goodbyeanother_server). - Correct
client/stateshape includingrequired_lead_time_ms/min_buffer_ms/set_static_delay. - Full controller role (incl. repeat/shuffle merge logic for old vs new servers), color role, visualizer role (typed frames).
- Clock-sync refinements: burst-then-best (only lowest-RTT sample per burst enters the filter), adaptive forgetting, drift gated on SNR, and live re-scheduling of every buffered chunk when the clock estimate moves.
- Real test suite (~1,900 lines, ~100 cases) + CI run against the official Sendspin/conformance harness.
What it does NOT cover (we’d keep our code)
- No codec decoders - FLAC/Opus decode stays ours (MediaCodec).
- No audio output -
AudioPlayeris an interface;SyncAudioPlayer(AudioTrack, DAC-gated start, insert/drop correction) stays ours entirely. ItsPcmDriftCorrectoris a passive utility capped at +/-0.2% by default (vs our +/-2%) and is never invoked by the library itself. - No
stream/request-format, noexternal_source,client/state.statehardcoded to “synchronized” (no error reporting). - None of our app-level machinery: proxy/WebRTC remote modes, LOCAL->PROXY fallback, stall watchdog, adaptive time-sync bursts, low-memory mode, freeze/thaw of the time filter across reconnects.
Pros of switching
- Spec compliance maintained upstream: the P1/P2 fixes above (state shape, timing fields, controller role) are already correct there, and conformance CI catches regressions as the spec moves.
- Server-initiated connections + multi-server arbitration for free - our biggest missing feature, and the spec-recommended connection method.
- New roles (color, visualizer) arrive as typed APIs without us writing parsers.
- Shared maintenance with other official SDKs; documented workarounds for known aiosendspin/MA server quirks (seek handoff, stale post-seek chunks) that we otherwise discover the hard way.
- Same stack we already use (OkHttp, coroutines, Kotlin); clean StateFlow surface that maps well onto our service architecture.
Cons of switching
- v0.3.0, days old, effectively one squashed commit; data-class APIs already churned once. Expect breaking changes for months.
- It replaces exactly the layer of ours that is most battle-tested (protocol handler, time filter, buffer state machine) while leaving the hard parts (AudioTrack sync engine, decoders) untouched - high migration risk, low code-deletion payoff in the short term.
- Feature regressions for us today: no
client/stateerror reporting (we rely on error -> mute -> rebuffer behavior per spec), nostream/request-format, noexternal_source. - Our connection coordinator (local/proxy/WebRTC, fallback, watchdog) would need rework to wrap the library’s connection model; the library assumes it owns the WebSocket lifecycle.
- Library scopes are never torn down (no
close()); fine as a singleton, but a papercut for our reconnect-heavy lifecycle. - Slight spec drift inside the library itself: it still implements the
removed
pitchvisualizer type (16-21 instead of 16-20). - Packaging papercut: depends on
org.json(needsexclude(group="org.json")to avoid duplicate classes on Android).
Recommendation
Do not switch now. Fix P1/P2 in our code (small, well-understood diffs), and revisit the library at ~v1.0 / once the spec leaves “public preview”. Two cheaper ways to get its benefits meanwhile:
- Run the official Sendspin/conformance harness against SendSpinDroid in CI (the harness is implementation-agnostic; the JVM repo shows the pattern).
- Port specific ideas: burst-then-best time-sync sampling and live buffer
re-scheduling on clock updates are both directly applicable to
TimeSyncManager/SyncAudioPlayer.
If/when server-initiated connections become a requirement (e.g. MA stops
polling _sendspin-server._tcp clients), reconsider: that subsystem is the
strongest single argument for adopting the library rather than building it.
Suggested fix order
- P1.1 client/state shape (top-level
state) - small, test against MA - P1.2 add
required_lead_time_ms+min_buffer_ms- small - P1.3 goodbye reason
restart- one-liner - P1.4 static_delay_ms int + clamp - one-liner
- P4.11 real software_version - one-liner
- P2.5 controller state parsing + missing commands - medium
- P3.10 metadata progress extrapolation - small/medium, visible UX win
- P3.6 external_source on audio-focus loss - medium, nice UX win
- P3.7 set_static_delay command - small
- P3.8 stream/request-format - medium
- P3.9 server-initiated connections - large, decide build-vs-library first