- Zig 88%
- Nix 8.5%
- Python 3.5%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
test / test (push) Successful in 12m27s
test / vm (music-assistant-stable) (push) Successful in 2m24s
test / vm (music-assistant-unstable) (push) Successful in 3m0s
test / vm (discovery) (push) Successful in 3m14s
test / vm (pair-server) (push) Successful in 2m37s
test / vm (pairing) (push) Successful in 2m51s
test / docs (push) Successful in 8m1s
test / conformance (push) Successful in 50m59s
The nixpkgs update brought Gradle 9.8.0, whose embedded Kotlin pulls kotlin-stdlib 2.4.10 onto the buildscript classpath in place of 2.4.0, and the offline build could not find it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016w26TNk91PhGX1WbrSedvi |
||
| .forgejo/workflows | ||
| LICENSES | ||
| src | ||
| tests | ||
| tools | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| build.zig.zon.nix | ||
| flake.lock | ||
| flake.nix | ||
| package.nix | ||
| README.md | ||
| REUSE.toml | ||
zig-sendspin
Sendspin in Zig, both sides of it: a player client, which joins a Music Assistant server as one of its players and plays what it sends in step with every other player in the group, and a server, which serves players of its own the same way.
Sendspin is the Open Home Foundation's protocol for synchronized playback. The server sends audio ahead of time, each chunk stamped with the moment on the server's clock at which it is to be heard. A player keeps an estimate of that clock and plays each sample at its moment. Two players in the same room then sound as one.
The API documentation, generated from the doc comments, is published at
https://jeff.jcollie.page/zig-sendspin/, one part for each of the four
modules: sendspin, the player client; sendspin_listener, where a server
connects to a player; sendspin_server, the server; and
sendspin_discovery, finding and being found by mDNS.
const sendspin = @import("sendspin");
var scheduler: sendspin.Scheduler = .init(gpa, .{});
defer scheduler.deinit();
var player: sendspin.Player = .init(gpa, &scheduler);
defer player.deinit();
const formats = sendspin.Player.formats(48000);
var client: sendspin.Client = .init(io, gpa, .{
.address = .{ .hostname = "media01" },
.token = music_assistant_token,
.hello = .{
.client_id = "my-player",
.name = "My Player",
.formats = &formats,
.buffer_capacity = 48000 * 2 * 3 * 5,
},
}, player.sink());
var runner = try io.concurrent(sendspin.Client.run, .{&client});
defer runner.cancel(io) catch {};
// Then, on the audio device's real-time thread, once per cycle. It says
// whether there is a stream at all, for a device that would rather stop
// asking when there has been none for a while.
const streaming = scheduler.fill(planes, frames, sendspin.Scheduler.now() + output_latency_us);
The server is the other half, in a module of its own, sendspin_server:
const server_mod = @import("sendspin_server");
var server: server_mod.Server = undefined;
try server.init(io, gpa, .{ .name = "My Server", .identity = server_key }, app.sink());
defer server.deinit();
var listener: server_mod.Listener = undefined;
try listener.init(io, gpa, &server, .{ .address = try .parse("0.0.0.0", 8927) });
defer listener.deinit();
var serving = try io.concurrent(server_mod.Listener.run, .{&listener});
defer serving.cancel(io) catch {};
// Then, from the task producing the audio: interleaved f32, in any format.
const group = server.default_group;
try group.start(.{ .sample_rate = 44100, .channels = 2 });
try group.write(frames); // waits while the players are far enough ahead
try group.end(); // once the players have heard the last of it
Requires Zig 0.17 and Linux.
What it speaks
Both generations of the protocol. Given an identity — a Curve25519 key pair,
which a player should keep, since its public key is its client id — Client
speaks the protocol of aiosendspin 9.1, which Music Assistant
2.10 serves:
- a Noise handshake,
Noise_KKpsk2_25519_ChaChaPoly_SHA256, done with zig-noise. The client sendsclient/init, the server sendsserver/initand Noise message 1, and the client answers with message 2; - every message after it is sealed: binary frames whose plaintext's first byte says what they carry, split into fragments when too big for one;
server/hello, answered byclient/hello, which asks for guest access: playback without pairing;server/activate, which grants the player role once the server has approved the guest — Music Assistant does so by itself — after which the client sends its state: its volume, whether it is available, and the timing the server schedules its audio by (static_delay_ms,required_lead_time_ms,min_buffer_ms).
A client that has never paired is admitted with the published sentinel PSK:
the connection is encrypted, but neither side has proven who it is. Pairing
gives it a long-term PSK instead, and Client pairs with a dynamic PIN
when given Options.pairing — somewhere to keep PSKs, and a way to show a
PIN — which is how Music Assistant pairs a player with a screen:
- the client offers the method in its hello, with the shortest PIN it
accepts; the server asks for it with
pairingin aserver/activate; - the client commits to a random nonce, the server sends one of its own, and the client derives the PIN from both and the handshake hash, and shows it;
- a person types the PIN into the server, and the two run CPace on it — the CFRG's PAKE, from zig-std-crypto-ext — each proving it knew the PIN with a confirmation tag;
- the client makes a long-term PSK and sends it encrypted under a key only the CPace run could give, and the two run the handshake again on it, inside the sealed channel.
Later connections to that server are on the long-term PSK, and the hello says so. A wrong PIN abandons the attempt without closing the connection, so the server can try again; ten in a row and the client stops, since going on would want a gesture on the device, which it has no way to make. The other methods, a static PIN and a pairing token, are not implemented.
secure.zig carries the handshake, as a small state machine over text
frames, and the sealed channel; pairing.zig the dynamic-PIN exchange, a
message at a time. Their tests run both against a server played in the test,
including a message that has to go in fragments, and check the PIN, the
commitment, the session id and the wrapped PSK against what aiosendspin
computes from the same inputs.
Without an identity it speaks aiosendspin 4.4's unencrypted protocol, which Music Assistant 2.8 serves and 2.10 still accepts from "legacy" clients.
With an identity it tries the sealed handshake first. An older server does
not know client/init: it closes the connection, or answers with something
other than server/init. The client then reconnects at once and speaks the
unencrypted protocol instead. That choice lasts for one connection, so a
server that is upgraded later is sealed with on the next reconnection. So a
player can always be given an identity, whichever Music Assistant it meets.
Either way the client connects through Music Assistant's proxy at
ws://<server>:8095/sendspin. The proxy wants a Music Assistant token, and the
client id, as its first message, and closes the connection on a bad token.
After that it passes Sendspin through untouched, sealed or not. With no token,
Client talks to a Sendspin server directly instead.
The player role is implemented, with the clock exchanges that keep it in step. Beyond that it handles the stream messages (stream/start,
stream/clear, stream/end), the server's volume and mute commands, and the
group's playback state, and Client.requestFormat asks for a different
format mid-stream with stream/request-format.
The other three roles are there too, offered with Hello.roles, beside the
player or instead of it:
- metadata: what is playing and where it has got to, from
server/state. aiosendspin 9.1 sends only what changed, a field left out unchanged and a null one cleared, where the specification sends the whole;Clientmerges each into the last either way, and hands the sink all of it.Metadata.positionreckons where the track is at a given moment from its timestamp and speed; - controller: how the group stands -- the commands the server takes,
volume, mute, repeat and shuffle -- and
Client.commandto play, pause, skip, seek, set the volume and the rest; - artwork: one to four channels, each an album's or an artist's picture in JPEG or PNG at a size the client names, the server scaling it to fit. Each image arrives whole in one binary message, types 8 to 11 for the four channels, as aiosendspin sends it. The specification has since split an image into an announcement and parts, which no server sends yet, and which on the wire cannot be told from the whole-image form: a part's flag byte is where the other has the top byte of its timestamp.
A client without the player role tells the server only that it is available, once it is activated.
Nothing but the handshake passes before the server's first
server/activate, which the specification requires: on a sealed
connection the clock exchanges wait for it.
The protocol has two ways of starting, and Client does both. run dials
the server, as above. A server may instead dial the player: Listener is
an HTTP server, zig-http's, with one WebSocket resource,
/sendspin by default, and hands each connection to it to Client.serve,
one at a time. It is a module of its own, sendspin_listener, so that a
player that only dials out does not take an HTTP server with it. The
handshake is the same either way, the client speaking first; only who
opened the socket differs. A player waiting to be dialed is found by mDNS,
through sendspin_discovery: see Finding and being found.
Options.trace is told of every message as it goes by, either way: its
direction, whether it was sealed, the WebSocket frame it went in, and its
type -- never what it said. The conformance adapter builds its protocol
evidence from it.
Player decodes FLAC, with zig-flac, Opus, with zig-opus,
and PCM at 16, 24 and 32 bits. It asks for each at the audio device's own
rate, so that the server does any resampling and the samples can go straight
out. By default it offers FLAC first: FLAC is lossless, so it sounds exactly
like PCM at about half the bytes on the network. Opus is offered last, or
first when the program asks for it, as it might over a slow link. Opus is
only offered at the rates it codes at: 48, 24, 16, 12 and 8 kHz.
On the wire a FLAC stream's codec_header is the fLaC signature and the
streaminfo block, base64. Every audio message is then one FLAC frame, stamped
with the moment its first sample is to be heard. An Opus stream has no header.
Each message is one raw Opus packet, 20 ms long, and its timestamp has
already been moved back by the encoder's pre-skip. zig-opus decodes every
mode: CELT, which is what music is coded in at streaming bitrates, and SILK
and hybrid, for speech. A packet that cannot be decoded is concealed as a
lost one would be, for as long as its table of contents says.
The server
Server speaks what the client does, from the other side, in aiosendspin
9.1's dialect where the specification has since moved on: the 9-byte audio
header, metadata sent as what changed, artwork as a whole image a message.
A client that opens with client/init gets the sealed protocol, the server
as the Noise initiator, on the client's long-term PSK if the program's
Options.psks has one and the sentinel if not; with
Options.allow_unencrypted, one that opens with client/hello gets
aiosendspin 4.4's. Listener accepts connections, a zig-http server with
one WebSocket resource as the client's Listener is, and Server.connect
dials a player that listens; the handshake is the same either way.
A client that has never paired plays as a guest, if it offers to and
Options.allow_guests lets it. With Options.pairing a guest that offers a
dynamic PIN is asked to pair before anything else: the program is asked for
the PIN the player shows -- a person types it in -- CPace runs on it, the
client's new PSK is unwrapped and handed to the program to keep, and the
handshake runs again on it inside the channel. A wrong PIN fails the
attempt and the next one starts; the PIN itself is never logged.
Every client starts in the server's default group -- a player once its
first client/state has said the lead it needs, and the program is told
Sink.onJoin then -- and createGroup and move make others. A group's
audio is the program's: Group.start with the source's rate and channels,
write with interleaved f32, clear for a seek or a skip, end at the
end. stream/end tells a player that playback is over, and by default it
stops and drops what it holds, so end sends the last of the audio at once
and stream/end only when that has been heard, waiting up to the send-ahead
window; clear first to stop at once. Each player is sent it in a format of its
own, from those its hello offered -- the source's rate and channels if it
takes them, else its first choice, resampled with zig-resample
and its channels mixed to fit -- and players that chose the same format
share one encoding of it:
- PCM at 16, 24 or 32 bits;
- FLAC, a frame a chunk, with the signature and streaminfo as the
codec_header; - Opus, a 20 ms packet a chunk, stamped back by the encoder's lookahead.
Every chunk is 20 ms and stamped with the moment it is to be heard. The
first is set as far ahead as the slowest player in the group asks for --
its min_buffer_ms and static_delay_ms, or its required_lead_time_ms --
and write waits while the stream is further ahead of the clock than the
players can hold. A player that joins mid-stream, or asks for another format
with stream/request-format, gets the stream from where it has got to, in
step with the rest. A stream never waits on a player that is slow to read:
its chunk is dropped instead. A format a client offers that no
encoder here can make -- no channels, a rate of nothing, Opus at a rate it
does not code -- is dropped from its offer when the hello is read.
Group.setMetadata sends what is playing to the clients with the metadata
role, as what changed; setController the commands the group takes and its
repeat and shuffle, to controllers, whose commands come to the program's
Sink.onCommand -- all but volume and mute, which the server carries out
itself by the specification's algorithm, keeping the players' levels
relative to each other. setArtwork has the program's
Options.artwork callback render a picture at each channel's size and
format, so the library takes no image dependency.
Finding and being found
sendspin_discovery advertises a player or a server by mDNS, and finds
players, under the names aiosendspin uses and Music Assistant looks for:
- a player waiting to be dialed is
_sendspin._tcp, its instance named for its client id, with TXTnameandpath-- aiosendspin ignores a player whose TXT has nopath-- and a server finds it and dialsws://address:port/path; - a server is
_sendspin-server._tcp, with TXTnameandpath.
const discovery = @import("sendspin_discovery");
// A player: Music Assistant finds it and dials it on `Listener`'s port.
const advertiser = try discovery.Advertiser.start(io, gpa, environ, .{
.kind = .player,
.instance = &sendspin.secure.peerId(identity.public),
.name = "Kitchen",
.port = discovery.player_port,
});
defer advertiser.stop();
// A server: every player found is dialed, once, for as long as it lasts.
var dialer: discovery.Dialer = .init(io, gpa, .{ .context = &server, .connect = connectPlayer });
defer dialer.deinit();
const finder = try discovery.Finder.start(io, gpa, environ, dialer.handler(), .auto);
defer finder.stop();
Who says it. The system's Avahi daemon, through zig-avahi
on the system bus, when there is one and it lets this user publish: it holds
port 5353 and the host's name already, and handles conflicts and changes of
network. Otherwise -- no daemon, or one that refuses, as NixOS's does unless
services.avahi.publish.userServices is on -- a responder of the program's
own, zig-dns-client's, answers for a host name of its own,
<host>.local, made from the instance's name. backend() says which it
was. Finding goes the same way: Avahi's browser, or zig-dns-client's monitor.
A player is reported by its first IPv4 address, as aiosendspin takes one.
It is a module of its own, so that a player that is only ever given a URL does not take D-Bus and a DNS library with it.
How it fits together
| Piece | What it does |
|---|---|
message |
The JSON and binary messages, encoded and decoded, with no I/O |
TimeFilter |
The server's clock as seen from here: a Kalman filter over offset and drift |
secure |
The Noise handshake and the sealed channel after it |
pairing |
Pairing with a dynamic PIN: the PIN, the CPace run, the PSK sent wrapped |
pcm |
Little-endian PCM to f32, including the packed 24-bit form |
Player |
A Client.Sink that decodes FLAC, Opus or PCM into a Scheduler |
Scheduler |
Chunks in with their timestamps, samples out when they are due |
Session |
One WebSocket, plaintext or TLS |
Client |
The connection kept up: handshake, clock exchanges, reconnecting |
Listener |
Where a server connects to the player, handing each connection to Client.serve |
server_message |
The server's side of the messages: what it writes, and what a client says, read |
sendspin_server.Server |
Connections, handshakes, pairing, groups; the program's Sink |
sendspin_server.Connection |
One client, with a queue of what goes to it and a task of its own writing it |
sendspin_server.Group |
Clients playing together: their audio, metadata, controller state and artwork |
sendspin_server.Stream |
A group's audio, encoded once a format and stamped for every player |
sendspin_server.Listener |
Where clients connect to the server |
sendspin_discovery.Advertiser |
A player or server advertised by mDNS, through Avahi or a responder of its own |
sendspin_discovery.Finder |
The players on the network, found and resolved |
sendspin_discovery.Dialer |
Each player found dialed, once, for as long as the connection lasts |
TimeFilter is a port of aiosendspin's SendspinTimeFilter, line for line. It
has to agree with every other Sendspin client about where the server's clock is.
Its test checks it against numbers the Python produced from the same
measurements.
Scheduler.fill runs on the audio device's real-time thread, so it never
waits. It only tries the lock the connection's task pushes under, and it
plays silence for any cycle in which it loses the race. It locks on to the
first sample: audio that is already late is skipped and audio that is early is
padded with silence, so playback starts exactly on time. After that it plays
straight through and watches the difference between where the samples are and
where they should be. Past 2 ms off it plays one frame twice or skips one,
which is inaudible. Past 20 ms off it locks on again.
Scheduler.fadeOut fades what is playing to silence and holds it there, for
a program that knows the server is about to replace the audio: a skip or a
seek takes Music Assistant about a second to act on, and a second of the old
track carrying on sounds like the button did nothing. The next stream start,
stop or clear ends the silence. If nothing replaces the audio within the hold
time, it fades back in.
The scheduler's room is reckoned in time: 32 seconds by default, about 12 MB
at 48 kHz in stereo. Music Assistant sends no further ahead than 30 seconds.
Within that it sends as many bytes as the player's buffer_capacity allows,
and that capacity counts encoded bytes, which in a quiet passage of FLAC
can be a great many seconds. A buffer sized from the byte capacity overflows
there, and every frame dropped is a gap to lock on across.
Scheduler.now is the clock everything is measured by: CLOCK_MONOTONIC in
microseconds, the same on every thread. The caller adds the audio device's
output latency to it to give fill the moment the samples will be heard.
That is the player's own business, as it is in aiosendspin's: the
static_delay_ms a player tells the server of is a delay a user sets on top,
not the device's.
A server only learns what a player can play from its hello, so
Client.setFormats, for a device that has changed rate, offers the new
formats by connecting again: the server is told the player is restarting,
and the client reconnects as it does after any disconnection.
Trying it
$ SENDSPIN_TOKEN=... zig build && ./zig-out/bin/sendspin-probe media01 60
connected to Music Assistant (c5cb638ad5234ef4b346f16f7632d625)
group playing
stream: pcm 48000 Hz, 24 bit, 2 channels
clock ±862 µs buffered 4974 ms level 0.040 locks 1 underruns 0 late 0 corrections 0
With SENDSPIN_IDENTITY_FILE naming a file, the probe connects encrypted,
keeping its key in that file so that the server sees the same player each run.
With SENDSPIN_OPUS set, it asks for Opus first rather than FLAC. With
SENDSPIN_RATE_FILE naming a file, it looks there once a second for a sample
rate, and when one appears it behaves as a sound card that has switched to it.
With SENDSPIN_LISTEN naming a port, it does not dial: it waits there to be
dialed, and advertises itself as _sendspin._tcp, which is how Music
Assistant finds a player nobody told it about.
sendspin-probe registers as a player named after itself and plays into
nothing. A pretend audio device takes 20 ms of samples every 20 ms, on an
absolute schedule as a sound card's clock would. Once a second the probe prints
the clock estimate, how much audio is waiting, the level of what was "played",
and the scheduler's counters. Start something playing on it from Music
Assistant to see the audio arrive.
sendspin-pair-server is the server's side of pairing, on its own: players
connect to it, and one that offers a dynamic PIN is asked to pair, the PIN
it shows typed on the server's standard input.
$ SENDSPIN_IDENTITY_FILE=server.key SENDSPIN_PAIRINGS=pairings ./zig-out/bin/sendspin-pair-server 8927
listening on 8927, as p_04w5puvKFjZbFu03pcf6IeuXj1t5OQSm3AmxwkqHs
pin wanted: 6 digits, shown by Kitchen
paired pcK9PaqZWO3inJnOqW_cSvGlDJQWLoF1_0Ih7p_3Dh8
joined Kitchen (pcK9PaqZWO3inJnOqW_cSvGlDJQWLoF1_0Ih7p_3Dh8), paired
It keeps its key in SENDSPIN_IDENTITY_FILE and the long-term PSKs pairing
makes in SENDSPIN_PAIRINGS, so a player paired once connects on its PSK
thereafter. It streams nothing. It advertises itself as
_sendspin-server._tcp, and with SENDSPIN_DISCOVER set it finds the
players on the network and dials each, as Music Assistant does.
Building and testing
$ nix develop
$ zig build test --summary all
$ zig build check # everything compiles, including the probe and docs server
$ zig build docs-serve # the API documentation, at http://localhost:8000/
$ reuse lint
The library is also tested as a player of a real Music Assistant, from first boot, in a NixOS virtual machine. That runs once for each NixOS release the flake names:
| Check | NixOS | Music Assistant | aiosendspin | Connection |
|---|---|---|---|---|
music-assistant-stable |
26.05 | 2.8.7 | 4.4.0 | sealing refused, so unsealed |
music-assistant-unstable |
unstable | 2.10.3 | 9.1.1 | sealed with Noise |
zig-musicassistant's test
helper sets the server up and scans some generated music. sendspin-probe
then joins through the server's authenticated proxy, always with an identity,
so that against 2.8.7 it has to fall back. The server is asked to
play a track to it, and the test checks that the stream starts, the clock
locks to the server's, and the track's tone arrives at a level silence never
has. Then the probe behaves as a sound card that has switched to 44.1 kHz,
and what is played to it next has to arrive at that rate.
$ nix build -L .#checks.x86_64-linux.music-assistant-stable
$ nix build -L .#checks.x86_64-linux.music-assistant-unstable
Pairing is tested against the other side's reference implementation:
pairing runs aiosendspin 9.1.1's own server, driven by
tests/nixos/pairing_server.py, which asks the probe to pair with a dynamic
PIN twice — once answering with a PIN other than the one the probe shows,
which the probe has to refuse and stay connected, and once with the right
one. Then both sides must hold the same long-term PSK, and the probe, started
again, must connect on it.
The other way round, pair-server runs zig-sendspin's server,
sendspin-pair-server, against aiosendspin 9.1.1's own client, driven by
tests/nixos/pairing_client.py: a wrong PIN typed first, which the client
refuses, then the right one, after which both hold the PSK and the client,
started again, connects on it with no PIN asked for.
$ nix build -L .#checks.x86_64-linux.pairing
$ nix build -L .#checks.x86_64-linux.pair-server
discovery is finding and being found by mDNS, on three machines: desk
runs Avahi with user publishing on, bare has no Avahi, and ma runs
aiosendspin's own server with discovery on, as Music Assistant does.
aiosendspin's server has to find the probe on each -- advertised through
Avahi on one, by the probe's own responder on the other -- and dial it;
sendspin-pair-server has to find aiosendspin's own listening client,
advertised with python-zeroconf, and dial it; and it has to be found itself
as a _sendspin-server._tcp. On bare, the probe's responder and
python-zeroconf share port 5353. Each machine's default route goes by the
network they share, since aiosendspin sends its queries out of that
interface alone.
$ nix build -L .#checks.x86_64-linux.discovery
The Sendspin conformance matrix
The Sendspin conformance harness runs each implementation's
server against each implementation's client, scenario by scenario, and
compares what the two sides say happened. conformance runs it in a NixOS
virtual machine, with zig-sendspin added to it as a client and as a server:
tools/conformance.zig is the client's adapter, built as
sendspin-conformance, and tools/conformance_server.zig the server's,
sendspin-conformance-server. tests/nixos/conformance_run.py adds both to
the harness's registry and runs the matrix from aiosendspin 9.1.1,
sendspin-go and zig-sendspin as servers to zig-sendspin,
aiosendspin, sendspin-go, sendspin-cpp,
sendspin-rs, sendspin-js,
sendspin-dotnet and sendspin-jvm as
clients. The harness, each of those
implementations and the fixture's home, sendspin-cli, are flake inputs,
pinned in flake.lock, and every adapter is built by Nix, since the test
has no network:
- sendspin-cpp's own CMake dependencies are fetched at the tags it names;
- sendspin-rs's adapter's crates come from
tests/nixos/sendspin-rs-client.Cargo.lock, the harness's lockfile brought up to the pinned sendspin-rs bycargo update -p sendspin; - sendspin-js is built with its own
yarn.lock, and its adapter importswsfrom sendspin-js'snode_modules; - sendspin-dotnet is pinned to v9.3.3, its latest release and the API the
harness's adapter is written for -- 10.0, on its main branch, replaced
it -- and its adapter's NuGet packages are in
tests/nixos/sendspin-dotnet-client.deps.json, whichnix build .#conformance-harness.dotnetClient.fetch-deps && ./resultregenerates; - sendspin-jvm's adapter is built by Gradle, which Nix hands the Maven
artifacts recorded in
tests/nixos/sendspin-jvm-client.deps.json;nix build .#conformance-harness.jvmClient.mitmCache.updateScript && ./resultrecords them again.
Three of the adapters are patched, each for an API its library has since
changed, with --replace-fail so that the patch fails the build once the
harness catches up: sendspin-cpp removed the send_command overload its
adapter calls, sendspin-rs's ControllerCommand has grown the seek
command's fields, and sendspin-dotnet's IAudioPipeline has grown
ReanchorTiming and OutputLatencyChanged, which the adapter's pipeline,
hashing what arrives, has no use for.
zig-sendspin is in it twice. zig-sendspin has an identity and seals its
connections, as it does with Music Assistant 2.10, on the long-term PSK the
harness shares with its server; zig-sendspin-unsealed has none and speaks
aiosendspin 4's unencrypted protocol, as it does with 2.8. Where the
scenario says so the server dials the player, and Listener answers. As a
server it is in twice too: zig-sendspin seals unless the scenario allows
the legacy protocol, as aiosendspin's server does, and
zig-sendspin-unsealed allows it in every scenario, so that the clients
that speak only the legacy protocol -- sendspin-go's, sendspin-cpp's,
sendspin-rs's, sendspin-dotnet's and sendspin-jvm's --
meet every one of its roles. Either one seals with a client that does. The
server
adapter decodes the harness's FLAC fixture with zig-flac, streams it through
a Group in 100 ms writes, and draws the harness's reference picture for the
artwork scenario with z2d. The test passes when every case below does:
| Server | Client | Scenario | What matches |
|---|---|---|---|
| aiosendspin | zig-sendspin |
client-initiated-pcm, server-initiated-pcm, server-initiated-pcm-24bit |
the PCM, sample for sample |
| aiosendspin | zig-sendspin |
server-initiated-flac, server-initiated-opus |
the codec header and every chunk's bytes |
| aiosendspin | zig-sendspin |
server-initiated-protocol-baseline-v1 |
all five protocol assertions |
| aiosendspin | zig-sendspin |
client-initiated-request-format-pcm, client-initiated-request-format-flac |
the stream restarts in the format asked for |
| aiosendspin | zig-sendspin |
server-initiated-metadata, server-initiated-controller, server-initiated-artwork |
the metadata as sent; the command the server took, and its repeat and shuffle; the image's bytes |
| aiosendspin | zig-sendspin-unsealed |
server-initiated-legacy-unencrypted |
the PCM, sample for sample |
| sendspin-go | zig-sendspin-unsealed |
client-initiated-pcm, server-initiated-pcm |
the PCM, sample for sample |
| sendspin-go | zig-sendspin-unsealed |
server-initiated-metadata, server-initiated-controller, server-initiated-artwork |
as against aiosendspin |
| zig-sendspin | zig-sendspin |
every scenario above but the legacy one | as against aiosendspin |
| zig-sendspin | aiosendspin | client-initiated-pcm, server-initiated-pcm, server-initiated-pcm-24bit, server-initiated-flac, server-initiated-protocol-baseline-v1, server-initiated-metadata, server-initiated-controller |
as against zig-sendspin's client |
| zig-sendspin | zig-sendspin-unsealed, sendspin-go, sendspin-cpp, sendspin-jvm |
server-initiated-legacy-unencrypted |
the PCM, sample for sample |
zig-sendspin-unsealed |
zig-sendspin, aiosendspin |
as from zig-sendspin |
as from zig-sendspin |
zig-sendspin-unsealed |
zig-sendspin-unsealed |
every scenario but protocol-baseline | as against aiosendspin |
zig-sendspin-unsealed |
sendspin-go | client-initiated-pcm, server-initiated-pcm, server-initiated-flac, server-initiated-legacy-unencrypted, server-initiated-metadata, server-initiated-controller, server-initiated-artwork |
as against aiosendspin |
zig-sendspin-unsealed |
sendspin-cpp | every scenario but protocol-baseline and the two request-format ones |
as against aiosendspin |
zig-sendspin-unsealed |
sendspin-rs | every scenario but protocol-baseline, Opus and the legacy one | as against aiosendspin |
zig-sendspin, zig-sendspin-unsealed |
sendspin-js | server-initiated-metadata, server-initiated-controller |
as against aiosendspin |
zig-sendspin-unsealed |
sendspin-jvm | every scenario but protocol-baseline and Opus | as against aiosendspin |
zig-sendspin-unsealed |
sendspin-dotnet | client-initiated-pcm, server-initiated-pcm, server-initiated-pcm-24bit, server-initiated-flac, server-initiated-metadata, server-initiated-controller, server-initiated-artwork |
as against aiosendspin |
The PCM hash is over every sample as zig-sendspin's pcm.decode made it.
The protocol evidence is built from each side's trace, the direction, frame
and type of every message in order, and each assertion is checked against
it: the handshake in its order and frames, nothing before the first
server/activate, only offered roles activated, the first client/state
before any binary data, and a stream in an offered format with its chunks
stamped in order.
The other cases stay red in the report, for reasons that are not faults of zig-sendspin's:
- aiosendspin admits only sealed connections, outside the one scenario about the legacy protocol;
- sendspin-go's server adapter takes a single, unsealed connection and gives
up when it opens with
client/init, so a client that seals never gets to fall back. aiosendspin's own client fails it the same way, here and in the published matrix; - sendspin-go's protocol-baseline and 24-bit cases fail for every client, and it streams FLAC at 24 bits whatever the client offered, which the harness fails as a format the client never declared -- as it does for sendspin-jvm's client in the published matrix;
zig-sendspin's server seals unless the scenario allows the legacy protocol, as aiosendspin's does, and sendspin-go's, sendspin-cpp's, sendspin-rs's, sendspin-dotnet's and sendspin-jvm's clients speak only the legacy protocol;- the protocol-baseline scenario holds a client to opening with
client/init, which no client that speaks only the legacy protocol does; - sendspin-go's client adapter declines Opus,
stream/request-format, and the 24-bit and protocol-baseline scenarios, sendspin-cpp's declinesstream/request-format, sendspin-rs's and sendspin-jvm's decline Opus, and sendspin-dotnet's declines both; - sendspin-js's client adapter takes part only in the two PCM scenarios, the legacy one, metadata and controller, and offers only 48 and 44.1 kHz stereo. zig-sendspin's server resamples the harness's 8 kHz mono fixture to what the client offered, and the harness, which compares the PCM with the fixture's own samples, fails every one of those streams: its own server passes only by sending the fixture as it is, in a format the client never offered;
- sendspin-rs's and sendspin-dotnet's client adapters fail the legacy scenario from every server, aiosendspin's included: each counts audio only in the scenarios it lists as the player's, and that one is not in either list;
- aiosendspin's client adapter declines Opus and
stream/request-format, and its artwork case never starts: it names the channel's sizewidthandheight, where aiosendspin 9.1.1 hasmedia_widthandmedia_height.
$ nix build -L .#checks.x86_64-linux.conformance # the report is in result/results/
$ nix run .#conformance-harness -- --results-dir results # the same, without a VM
Fuzzing
Everything a server sends reaches a player unchecked, so the paths it takes
are fuzzed: the text of each message, whole sessions — clock exchanges,
stream/start in each codec, audio frames and the audio device asking for
samples — played into a Player and its Scheduler, the sealed channel's
frames and fragments, the Noise handshake, a server forging its side of a
pairing, and PCM. Everything a client sends reaches the server the same way,
so those are fuzzed too: a client's messages, the server's side of the
handshake, a client forging its side of a pairing, and a group's stream --
any source, any formats offered, samples with NaN and infinity among them,
players joining and leaving, clears and the end. Each target is a property
rather than an example: the call returns, frees what it allocated, hands the
device only finite samples, opens a sealed message as the message that was
sealed, never hands a PSK to a server that could not have known the PIN nor
takes one from a client that could not have, and reads only formats from a
hello that the encoders can make. The seeds are real sessions and
handshakes, with FLAC and Opus encoded at build time by
tools/fuzz_corpus.zig, and zig build test runs the properties on them.
$ zig build fuzz --fuzz # Zig's fuzzer, until interrupted
$ zig build fuzz --fuzz=1M # a bounded run, then a report
$ zig build fuzz-run # a minute of each target
$ zig build fuzz-run -- --seconds 300 --target sessions
$ zig build fuzz-run -Dfuzz-optimize=debug -- --input fuzz-findings/x.bin --target sessions
zig build fuzz --fuzz is Zig's own fuzzer, steered by coverage, which it
finds only in a test binary built by LLVM; build.zig asks for that. A
finding prints input saved to '.zig-cache/f/crash' above its report.
fuzz-run is a loop of this project's own in tools/fuzz.zig, with no
coverage but with a time limit, a seed to repeat a run by, and a way to run
one input again; that file says more, and how its
inputs are made. A failing input is written to fuzz-findings/, and
--input runs it again, in Debug for a stack trace worth reading.
Using it
$ zig fetch --save git+https://git.jcollie.dev/jeff/zig-sendspin.git#main
const sendspin = b.dependency("sendspin", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("sendspin", sendspin.module("sendspin"));
// A server, too:
exe.root_module.addImport("sendspin_server", sendspin.module("sendspin_server"));
// Found, and finding, by mDNS:
exe.root_module.addImport("sendspin_discovery", sendspin.module("sendspin_discovery"));
Pipit uses it to be a player as well as a remote control, with zig-pipewire as the audio device.
Where this lives
The repository has three homes, and they hold the same history.
| Where | How to get it |
|---|---|
| Forgejo | git clone https://git.jcollie.dev/jeff/zig-sendspin.git |
| Tangled | git clone https://tangled.org/jcollie.dev/zig-sendspin |
| Radicle | rad clone rad:zpyZADMLnVR11ivHKk5oBzbztitH |
A Radicle repository is findable only by its ID, so that one is written out in
full: rad:zpyZADMLnVR11ivHKk5oBzbztitH.
Licensing
MIT, following the REUSE specification; reuse lint checks it.
References cited
Kept in the zig-sendspin Zotero collection.
- Fette, Ian, and Alexey Melnikov. The WebSocket Protocol. RFC 6455. Internet Engineering Task Force (IETF), December 2011. https://www.rfc-editor.org/info/rfc6455.
- Music Assistant. Music Assistant server: Sendspin proxy
(
sendspin_proxy.py), versions 2.8.7 and 2.10.3. https://github.com/music-assistant/server/blob/2.8.7/music_assistant/controllers/webserver/sendspin_proxy.py. - Open Home Foundation. aiosendspin: Async Python library implementing the Sendspin Protocol, versions 4.4.0 and 9.1.1. https://github.com/Sendspin-Protocol/aiosendspin.
- van Beurden, Martijn, and Andrew Weaver. Free Lossless Audio Codec (FLAC). RFC 9639. Internet Engineering Task Force (IETF), December 2024. https://www.rfc-editor.org/info/rfc9639.
- Valin, JM., K. Vos, and T. Terriberry. Definition of the Opus Audio Codec. RFC 6716. Internet Engineering Task Force (IETF), September 2012. https://www.rfc-editor.org/info/rfc6716. What an Opus packet is, which is what each audio message of an Opus stream carries.
- Perrin, Trevor. The Noise Protocol Framework, revision 34. 2018. https://noiseprotocol.org/noise.html.
- Cheshire, S., and M. Krochmal. Multicast DNS. RFC 6762. Internet
Engineering Task Force (IETF), February 2013.
https://www.rfc-editor.org/info/rfc6762. What
sendspin_discovery's own responder answers with when there is no Avahi to publish through. - Cheshire, S., and M. Krochmal. DNS-Based Service Discovery. RFC 6763.
Internet Engineering Task Force (IETF), February 2013.
https://www.rfc-editor.org/info/rfc6763. The
_sendspin._tcpand_sendspin-server._tcpinstances, and their TXTnameandpath. - Avahi project. Avahi: service discovery on Linux using mDNS/DNS-SD,
version 0.8. 2020. https://avahi.org/. Publishing and browsing for
users, which
sendspin_discoverygoes through, over zig-avahi, when it may. - Abdalla, Michel, Björn Haase, and Julia Hesse. CPace, a balanced composable PAKE. Internet-Draft draft-irtf-cfrg-cpace-21. Internet Research Task Force (IRTF), April 2026. Work in progress. https://datatracker.ietf.org/doc/draft-irtf-cfrg-cpace/. The PAKE pairing runs on the PIN.
- Open Home Foundation. Sendspin Protocol Specification. https://www.sendspin-audio.com/build/spec/.
- Sendspin. Sendspin Conformance: capability-aware conformance harness for
Sendspin implementations. https://github.com/Sendspin/conformance. The
adapter contract
tools/conformance.zigandtools/conformance_server.zigfollow, the summariesaiosendspin_server.pywrites, which the server's adapter matches, and the matrix theconformancecheck runs. - Sendspin. sendspin-go: Sendspin protocol implementation in Go. https://github.com/Sendspin/sendspin-go. The second server the matrix runs against, and a third client for zig-sendspin's server.
- Sendspin. sendspin-cpp: Sendspin protocol implementation in C++. https://github.com/Sendspin/sendspin-cpp. A fourth client for zig-sendspin's server.
- Sendspin. sendspin-rs: Sendspin protocol implementation in Rust. https://github.com/Sendspin/sendspin-rs. A fifth client for zig-sendspin's server.
- Sendspin. sendspin-js: TypeScript client library for the Sendspin synchronized audio protocol. https://github.com/Sendspin/sendspin-js. A sixth client for zig-sendspin's server.
- Sendspin. sendspin-dotnet: Sendspin SDK for .NET. https://github.com/Sendspin/sendspin-dotnet. A seventh client for zig-sendspin's server, at v9.3.3.
- Sendspin. sendspin-jvm: Sendspin protocol implementation for the JVM, in Kotlin. https://github.com/Sendspin/sendspin-jvm. An eighth client for zig-sendspin's server.