A Sendspin player client for Zig: synchronized playback from Music Assistant
  • Zig 88%
  • Nix 8.5%
  • Python 3.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 332b480c98
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
Conformance: the JVM client's Gradle lock for Gradle 9.8
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
2026-10-08 15:14:24 -05:00
.forgejo/workflows Move to Zig 0.17 2026-10-07 02:21:46 -05:00
LICENSES Sendspin for Zig: a player client, in step with the server's clock 2026-09-24 17:08:55 -05:00
src Move to Zig 0.17 2026-10-07 02:21:46 -05:00
tests Conformance: the JVM client's Gradle lock for Gradle 9.8 2026-10-08 15:14:24 -05:00
tools Move to Zig 0.17 2026-10-07 02:21:46 -05:00
.gitignore Fuzzing: messages, whole sessions, the sealed channel, the handshake, PCM 2026-09-26 14:19:57 -05:00
build.zig Move to Zig 0.17 2026-10-07 02:21:46 -05:00
build.zig.zon Move to Zig 0.17 2026-10-07 02:21:46 -05:00
build.zig.zon.nix Move to Zig 0.17 2026-10-07 02:21:46 -05:00
flake.lock Move to Zig 0.17 2026-10-07 02:21:46 -05:00
flake.nix Move to Zig 0.17 2026-10-07 02:21:46 -05:00
package.nix Move to Zig 0.17 2026-10-07 02:21:46 -05:00
README.md Move to Zig 0.17 2026-10-07 02:21:46 -05:00
REUSE.toml Test the server against sendspin-jvm in the conformance matrix 2026-09-29 08:47:05 -05:00

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:

  1. a Noise handshake, Noise_KKpsk2_25519_ChaChaPoly_SHA256, done with zig-noise. The client sends client/init, the server sends server/init and Noise message 1, and the client answers with message 2;
  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;
  3. server/hello, answered by client/hello, which asks for guest access: playback without pairing;
  4. 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:

  1. the client offers the method in its hello, with the shortest PIN it accepts; the server asks for it with pairing in a server/activate;
  2. 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;
  3. 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;
  4. 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; Client merges each into the last either way, and hands the sink all of it. Metadata.position reckons 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.command to 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 TXT name and path -- aiosendspin ignores a player whose TXT has no path -- and a server finds it and dials ws://address:port/path;
  • a server is _sendspin-server._tcp, with TXT name and path.
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 by cargo update -p sendspin;
  • sendspin-js is built with its own yarn.lock, and its adapter imports ws from sendspin-js's node_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, which nix build .#conformance-harness.dotnetClient.fetch-deps && ./result regenerates;
  • 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 && ./result records 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 declines stream/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 size width and height, where aiosendspin 9.1.1 has media_width and media_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.