Capability-aware conformance harness for local Sendspin implementations.
Current scenarios:
client-initiated-pcm(Client initiates connection and client wants PCM): start the server first, let the client discover/connect to it, advertise PCM as the only supported audio format, and compare canonical PCM hashesserver-initiated-pcm(Server initiates connection and client wants PCM): start the server first, let the client advertise a listener and PCM as its only supported audio format, let the server connect in, and compare canonical PCM hashesserver-initiated-protocol-baseline-v1(Server initiates a protocol baseline session): validates a spec-revision-pinned protocol trace for handshake order, activation, initial state, stream negotiation, and player chunk framing; it does not assess decoded or rendered audioserver-initiated-metadata(Server initiates connection and client wants Metadata): start the server first, let the client advertise a listener, let the server connect in, receive a metadata snapshot, and compare normalized metadata fieldsserver-initiated-artwork(Server initiates connection and client wants Artwork): start the server first, let the client advertise a listener, let the server connect in, receive album artwork bytes, and compare the encoded image hashserver-initiated-controller(Server initiates connection and client wants Controller): start the server first, let the client advertise a listener, let the server connect in, observe controller state, send a control command, and verify the server recorded itserver-initiated-flac(Server initiates connection and client wants FLAC): start the server first with PCM audio decoded fromalmost_silent.flac, let the client advertise a listener and FLAC as its only supported audio format, let the server connect in, encode the PCM to FLAC using the SDK, stream it to the client, and compare the transported FLAC bytes; where the client reports the audio it decoded and the stream kept the source's sample rate and channel count, also compare its canonical PCM hash against the source clip, so an intact but incomplete stream failsserver-initiated-opus(Server initiates connection and client prefers OPUS): start the server first with PCM audio decoded fromalmost_silent.flac, let the client advertise a listener and list OPUS first in its supported audio formats, followed by the PCM or FLAC entry every player must list, let the server connect in, and verify that it honoured that priority by encoding the PCM to OPUS using the SDK and streaming it to the client; compare the declared list, the negotiated format and the transported OPUS bytes. OPUS is lossy, so decoded audio is not compared against the source clipserver-initiated-pcm-24bit(Server initiates connection and client wants 24-bit PCM): start the server first, let the client advertise a listener and 24-bit PCM as its only supported audio format, let the server connect in, negotiate the 24-bit packed wire format, stream it to the client, compare canonical PCM hashes, and verify that the negotiated stream was 24-bit — a client SDK with no 24-bit decode path misreads the bytes and produces a hash mismatch, and a pair that streamed another depth has not tested the 24-bit pathclient-initiated-state-format-pcm(Client changes its preferred PCM bit depth viaclient/state): start the server first, let the client connect advertising two PCM formats (24-bit then 16-bit), let the server stream the higher-priority 24-bit format, have the client send aclient/statewhose playerformatprefers the 16-bit entry, and verify the server received that preference inclient/stateand the client observed a newstream/startin the 16-bit formatclient-initiated-state-format-flac(Client changes its preferred format from PCM to FLAC viaclient/state): start the server first, let the client connect advertising PCM then FLAC, let the server stream the higher-priority PCM format, have the client send aclient/statewhose playerformatprefers the FLAC entry, and verify the server received that preference inclient/stateand the client observed a newstream/startin the FLAC formatclient-initiated-metadata(Client initiates connection and client wants Metadata): start the server first, let the client discover/connect to it, receive a metadata snapshot, and compare normalized metadata fieldsclient-initiated-artwork(Client initiates connection and client wants Artwork): start the server first, let the client discover/connect to it, receive album artwork bytes, and compare the encoded image hashclient-initiated-controller(Client initiates connection and client wants Controller): start the server first, let the client discover/connect to it, observe controller state, send a control command, and verify the server recorded itserver-initiated-audio-chunk-framing(Server initiates connection and frames PCM audio chunks): start the server first, let the client advertise a listener and PCM as its only supported audio format, let the server connect in and stream, and have both adapters record each audio frame as it crossed the transport; verify that every frame is message type 4 behind the 13-byte header (type, int64 timestamp, uint32send_ahead), that the client read the same 13 bytes off each one, and that no chunk lasted longer than 150 ms or, the final chunk aside, less than 15 ms. The audio itself is not compared, andsend_aheadsaturation is not judged because no adapter can observe when a frame was transmitted
aiosendspin: real server adapter, including OPUS encoding, and real client adapter; the client SDK decodes PCM and FLAC only, so it does not advertise OPUSsendspin-dotnet: real client adapter for client-initiated PCM plus the server-initiated PCM, metadata, artwork, controller, and FLAC scenarios; server placeholderSendspinKit: real Swift client adapter for client-initiated PCM, Flac, and Opus plus the server-initiated PCM, Flac, Opus, metadata, artwork, and controller scenarios; server placeholersendspin-cpp: real C++ client adapter for client-initiated PCM plus the server-initiated PCM, 24-bit PCM, metadata, artwork, controller, FLAC, OPUS, legacy-unencrypted, protocol-baseline and audio-chunk-framing scenarios, evidenced from the library's message trace; it joins theclient-initiated-state-format-*scenarios as a player but fails them, since the library exposes no way to set theclient/stateplayerformat; server placeholdersendspin-go: not currently tested. Both roles are placeholders: the server library has no RC1client/initor Noise handshake, and the Go client adapter source inadapters/sendspin-go/is kept but no longer built or runsendspin-js: real Node.js client adapter driving the publicSendspinCoreSDK over an adapter-owned WebSocket; covers client-initiated PCM plus the server-initiated PCM, metadata, and controller scenarios (FLAC/OPUS decode andartwork@v1are not exposed by the public SDK in a headless Node environment); server placeholdersendspin-rs: real Rust client adapter for client-initiated PCM plus the server-initiated PCM, metadata, artwork, controller, and FLAC scenarios; server placeholdersendspin-server-cpp: real C++ server adapter for the client- and server-initiated PCM, 24-bit PCM, protocol-baseline, audio-chunk-framing, metadata, artwork and controller scenarios, the server-initiated FLAC and OPUS scenarios (both encoded by the library) and bothclient-initiated-state-format-*scenarios, built on the library's public API and evidenced from its message trace; the library has no unencrypted mode, so it takes no part inserver-initiated-legacy-unencrypted; client placeholder (its client counterpart issendspin-cpp)
The OPUS scenario is currently exercised only by the SendspinKit and sendspin-cpp clients against the aiosendspin and sendspin-server-cpp servers until other implementations opt in via supports_opus.
The client-initiated-state-format-* scenarios have no capability flag of their own: RC1 gives every player the client/state format preference, so a client that never sends one shows as a failed case. The FLAC variant still needs a client that can list FLAC (supports_flac). The aiosendspin and SendspinKit client adapters drive them today.
The client-initiated-metadata, client-initiated-artwork and client-initiated-controller scenarios have no capability flag of their own either: RC1 requires every server to support client-initiated connections, whichever roles the client brings. The aiosendspin, sendspin-dotnet, SendspinKit and sendspin-cpp client adapters drive all three over the outbound path they use for client-initiated-pcm. The sendspin-js client adapter drives the metadata and controller scenarios the same way; it declares no artwork role family, so the harness marks its client-initiated-artwork cases unsupported before launching anything. The sendspin-rs client adapter has an outbound path for player sessions only, so its cases fail as a harness gap rather than being left out.
The server-initiated-audio-chunk-framing scenario has no capability flag of its own either. A client adapter that is handed parsed chunks and never sees a raw frame reports that, and its case fails as a harness gap rather than being left out. Only the aiosendspin adapters, the sendspin-server-cpp server and the sendspin-rs and sendspin-cpp clients observe raw frames today.
An implementation without a client role gets no client column, and one without a server role gets no server row. Server rows are also left out per scenario, so the matrix only shows server rows that can actually run it. A client that implements the role but cannot run one scenario keeps its column: its case fails fast through a placeholder adapter and reads as unsupported.
The matrix is moving from media-oriented interoperability checks to normative protocol
conformance. server-initiated-protocol-baseline-v1 is the first authoritative
protocol test and is pinned to Sendspin spec revision
8c9577ea8719ad082d051ec13cc73ef15ed68948. That pin scopes this scenario's assertions
only. It is not the revision the matrix is audited against: the verdicts applied to every
case are judged against the spec checkout each run records in repositories.json and
shows on the report overview. The scenario intentionally fails until an adapter
can provide the required ordered protocol evidence described in
adapters/README.md. The current PCM, FLAC, OPUS, artwork,
metadata, and controller scenarios remain diagnostic interoperability checks during
this migration: a matching hash or normalized snapshot is evidence that two adapters
interoperated, not proof that either followed the specification.
python scripts/setup_workspace.py --clone
. .venv/bin/activate
python scripts/run_all.pyThat flow:
- clones the required repositories
- installs the Python harness and
aiosendspin - builds the adapter sources that are available locally
- runs the current matrix for the selected host environment
- generates the static HTML report
To run against a checkout somewhere else, set CONFORMANCE_REPO_<NAME> to its
absolute path, with the repository name upper-cased and - written as _ (for
example CONFORMANCE_REPO_SENDSPIN_CLI). A non-empty value must point at an
existing path: the harness stops at startup rather than fall back to repos/<name>.
An empty value counts as unset.
Run the full harness:
python scripts/run_all.py --results-dir results --build-report-path artifacts/build-report.jsonRun the full harness for an explicit host label:
python scripts/run_all.py \
--results-dir results \
--build-report-path artifacts/build-report.json \
--environment-id linux \
--environment-name LinuxRun a subset of the matrix:
conformance run --from aiosendspin,sendspin-rs --to SendspinKitRun the matrix with parallel case execution:
conformance run --jobs 4
python scripts/run_all.py --jobs 4The runner assigns a dedicated server port and client-listener port to each case,
so parallel cases do not fight over 8927/8928.
Build the adapter sources only:
conformance build --report-path artifacts/build-report.jsonGenerate the static site from existing results:
conformance report --results-dir resultsMerge multiple host result sets into one combined report:
python scripts/merge_results.py \
--output-dir artifacts/results \
artifacts/linux-results \
artifacts/macos-resultssendspin-validate checks a Sendspin client app on its own, without an adapter. It
is a strict Sendspin server, built on a bundled copy of aiosendspin, that your app
connects to. It records every frame, checks what the client sends against spec
1.0.0-rc1, and writes a report. The only prerequisite is
uv:
uvx --from git+https://github.com/Sendspin/conformance sendspin-validateIt asks how your app connects. In client-initiated mode it listens on port 8927
(--port), announced as _sendspin-server._tcp, and judges the first client to
connect. In server-initiated mode it lists the _sendspin._tcp clients it finds and
connects to the one you pick, taking it over from any server holding it idle;
--connect ws://host:port/path skips mDNS. --verbose streams every decoded frame to
stderr, and --fresh starts from a new validator identity. The identity is kept in the
platform user-data directory.
On the first connection it checks:
client/init:client_id,version,suite, and no undefined fields- the Noise handshake: message 2 carries exactly
{} - ordering: nothing but
client/helloorclient/goodbyebefore the firstserver/activate client/hello: support objects, role names, pairing methods,unpaired_access, no undefined fields, and (a warning only) the mDNS TXTname- the initial
client/state, itsavailablefield and its role objects, andclient/time - every rule aiosendspin's strict mode enforces, on every connection of the run, each cited to the spec line it comes from
It then walks you through the suites --suite names, in this order (default: all):
unpaired: U1 the Sentinel handshake; U2 refusing playback with unpaired access off (pairing_required); U3 accepting it with unpaired access on. U4, an activation no client may accept, runs only with--fault-injection.pairing: each method your app'sclient/hellooffers, in this order, with the checks of a method it does not offer reportedN/A:- P2 in the QR format: you paste the text of the QR your app shows.
- P1, pairing with the token (
SP:…) you paste from your app or leaflet, through both re-handshakes. - P2 in the digits format: you type the 6-digit code your app shows or speaks, then
a wrong one on purpose, which your app must reject, then the right one again. The
validator names a random non-English language in
server/hello, and asks which language a speaking app used. - P3, the static code: the validator first asks with the pairing window closed, then you open it and type the 8-digit code.
- P4: the validator cancels an attempt, which your app must drop cleanly. Then, if you opt in at its prompt, it leaves one idle until your app times it out, which takes about two minutes.
- P3 lockout, if you opt in at its prompt: five wrong static codes, which must close your device's pairing window, then a sixth attempt your app must hold back.
post-pairing: R1 reconnecting paired; R3 accepting the Sentinel once the validator forgets the pairing; R5 keeping the pairing and output delay across a full restart; R4server/unpair, then checking the app really dropped the pairing.
Every step waits for you, and every question accepts s (skip), r (retry) and ?
(not sure). The hidden --auto-answer flag is for the CI self-test only.
The run writes report.json, a self-contained report.html and protocol.log.jsonl
to ./sendspin-validate-<client-name>-<timestamp>/. Pairing secrets are redacted as
frames are recorded, and so is every code you enter. The exit status is 1 when any
check is FAIL or TOOL_ERROR.
The generated site includes:
- a global matrix overview with one section per test scenario
- a single matrix view per scenario without host-specific grouping in the UI
- the greener PCM scenarios listed first on the index page
- a separate static HTML page per test under
results/scenarios/ - a dedicated static HTML page per pairing under
results/cases/ - a dedicated static HTML page per implementation under
results/implementations/for linkable filtered overviews - the audio formats each client declared in
client/hello, marked exercised or not verified, on that client's implementation page - per-case status and reason with explicit server/client labeling
- summary, server, client, and build tabs on each dedicated case page when build data exists
- run artifacts under
results/data/, includingdeclared-formats.jsonfor the declared-versus-exercised data - the static report at
results/index.html - linked case artifacts for drill-down:
result.json, client/server summaries, and logs
src/conformance/: runner, adapters, fixture decoding, report generationsrc/conformance/validator/: thesendspin-validateclient validatorsrc/conformance/_vendor/andpatches/aiosendspin/: the aiosendspin the validator bundles, generated byscripts/vendor_aiosendspin.pyadapters/sendspin-go/: Go client adapter source, kept but not currently built or testedadapters/sendspin-server-cpp/server/: C++ server adapter sourceadapters/sendspin-dotnet/:.NETclient adapter sourceadapters/README.md: CLI contract for adaptersscripts/setup_repositories.py: clones implementation repositoriesscripts/setup_workspace.py: bootstraps a local Python environmentscripts/run_all.py: build + run + report orchestrationscripts/merge_results.py: merges multiple host result directories into one report.github/workflows/publish.yml: macOS GitHub Pages publishing.github/workflows/ci.yml: Linux unit tests, including the validator self-test