Cross-platform .NET SDK implementing the Sendspin Protocol for clock-synchronized multi-room audio streaming. Build players that sync perfectly with Music Assistant and other Sendspin-compatible players.
- Microsecond-precision sync - Kalman filter clock synchronization across devices
- Built-in codecs - PCM, FLAC, and Opus decoding out of the box
- Server discovery - mDNS-based automatic server finding
- Line-in source - the
source@v1role captures a local input (AUX, turntable, microphone) and streams it to the server; paired connections only - Sync correction built in -
TimedAudioBuffer.Read()applies the spec's strategy;ReadRaw()hands the error out for platforms with their own rate-control mechanism - NativeAOT & trimming - Fully compatible with
PublishAotfor single-file native executables - Cross-platform - Windows, Linux, macOS (.NET 8.0 / .NET 10.0)
dotnet add package Sendspin.SDKThe transport is encrypted end to end from 10.0.0 onward, and there is no downgrade negotiation — a 10.x client cannot talk to a server that predates the encrypted protocol. Pick the line that matches your server.
| SDK | Transport | Requires | Status |
|---|---|---|---|
| 10.x | Encrypted (Noise KKpsk2) |
aiosendspin 10.0.0 or later |
Current |
| 9.x | Plaintext | Any aiosendspin, 10.0.0 included |
Maintained for pre-encryption servers |
10.0.0 implements Sendspin/spec at tag 1.0.0-rc1.
aiosendspin 10.0.0 is the reference server's first release of that spec (Music Assistant
ships the 10.x line), and it is the floor: it gates connecting as much as pairing, so an older
server stops playback, not just pairing. The
interop workflow runs the SDK against the newest 10.x release,
in the strict mode Music Assistant uses, on every pull request. docs/SPEC-VERSION.md records the pin, what is checked
against the server, and where the SDK is known to differ from the spec.
A 9.x client keeps working against aiosendspin 10.0.0, which serves it on its legacy path,
so a server can be upgraded before its clients are.
The 9.x line stays maintained for now; it is not end-of-life. If you are on 9.x and your server supports the encrypted protocol, see MIGRATION-10.0.0.md — note especially that the client identity must be persisted, which is the one breaking change that fails silently rather than at compile time.
The encrypted transport protects a session's confidentiality and integrity, but what it authenticates depends on how the client is paired and configured.
- Pair before trusting. An unpaired connection runs under the published Sentinel PSK,
which authenticates nothing —
ISendspinClient.TrustLevelreportsUnpaired. Pairing establishes a per-server pre-shared key, and a session keyed with it reportsPaired. - Unpaired access is off by default; leave it off unless you need it.
ClientCapabilities.UnpairedAccessEnabled = truelets a server play to the client with no pairing record. Because the Sentinel PSK is a published constant and neither peer's static key is bound to its identity by any out-of-band exchange, such a session is vulnerable to an active man-in-the-middle on the local network. It still protects against passive observers, and it says nothing about which peer you are actually talking to. - Store keys where the platform protects them. The client identity and the pairing
records are long-lived secrets. The shipped file-backed stores write atomically and
restrict access where the platform supports it, but on Windows a file inherits its parent
directory's ACL — put it somewhere already user-scoped such as
%LOCALAPPDATA%. For hardware-backed protection, implementISendspinIdentityStoreandIPairingRecordStoreover DPAPI, Keychain, or the Android keystore; the identity blob is opaque, so the raw private key never leaves the SDK. - A static pairing code is a long-lived, low-entropy secret. The X25519 implementation used
by the PAKE is not constant-time, so a local attacker able to measure the client precisely
enough may learn something from timing. This matters most for
static_pairing_code, where the same short secret is reused indefinitely; a dynamic pairing code is a fresh per-session value.
A client's client_id is its Curve25519 public key, so its identity must persist across
restarts. Load it through an ISendspinIdentityStore — the SDK generates one on first run and
reuses it on every run after that, so there is no private-key persistence to hand-roll. See
Persisting the client identity for
platform stores (DPAPI, Keychain, Android keystore) and the file-permission notes.
using Microsoft.Extensions.Logging;
using Sendspin.SDK.Client;
using Sendspin.SDK.Connection;
using Sendspin.SDK.Connection.Noise;
// Generates and persists an identity on first run; loads the same one afterwards.
var identityStore = new FileSendspinIdentityStore(
Path.Combine(Environment.GetFolderPath(
Environment.SpecialFolder.LocalApplicationData), "MyApp", "identity.key"));
var identity = SendspinIdentity.FromStore(identityStore);
var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole());
// CreateForDial wires the identity, wire framing, and Noise session together so they
// can't drift apart. `await using`, not `using`: the client owns the connection it built,
// and only DisposeAsync closes it — a synchronous dispose leaves the socket open and the
// server reconnecting to an app that has exited.
await using var client = SendspinClientService.CreateForDial(
loggerFactory,
new SendspinClientOptions
{
Identity = identity,
Capabilities = new ClientCapabilities
{
ClientName = "My Player",
ProductName = "My Awesome Player",
Manufacturer = "My Company",
SoftwareVersion = "1.0.0"
}
});
client.GroupStateChanged += (sender, group) =>
{
Console.WriteLine($"Now playing: {group.Metadata?.Title} - {group.Metadata?.Artist}");
};
// A permanent handshake failure throws rather than being reported only as a state change,
// so a client that never subscribes to ConnectionStateChanged still learns about it.
try
{
await client.ConnectAsync(new Uri("ws://192.168.1.100:8927/sendspin"));
}
catch (SendspinHandshakeException ex) when (ex.Kind == HandshakeFailureKind.LegacyServer)
{
// The server predates the encrypted protocol. Upgrade it to aiosendspin 10.0.0 or
// later, or pin this SDK to the 9.x line. Retrying cannot help.
Console.Error.WriteLine(ex.Message);
return;
}
catch (SendspinHandshakeException ex) when (ex.Kind == HandshakeFailureKind.PairingRequired)
{
// The normal first contact with a new server: it offered playback, and this client is
// not paired with it and has unpaired access off. Pair, then connect again.
Console.Error.WriteLine(ex.Message);
return;
}
catch (SendspinHandshakeException ex)
{
// Any other Kind: the handshake was rejected, or the connection closed before it
// completed. ex.Message carries the detail.
Console.Error.WriteLine($"Handshake failed: {ex.Message}");
return;
}
// Send commands. A command goes out only while the controller role is active and the group
// lists it in GroupState.SupportedCommands (from server/state); otherwise it is dropped with
// a warning, so issue commands once GroupStateChanged has reported them.
await client.SendCommandAsync("play");
await client.SetVolumeAsync(75);You provide the audio output by implementing IAudioPlayer for your platform (WASAPI, PulseAudio, CoreAudio, SDL2, etc.) and pulling samples through TimedAudioBuffer.Read(), which applies the spec's sync correction for you. On a desktop-class device, SyncCorrectedSampleSource applies the same correction by trimming playback speed through a built-in resampler instead of stepping whole frames — smoother, same policy, no correction code on your side. See the NuGet package README for the player quickstart, the full API reference, the sync correction system, and migration guides.
| Project | Platform | Audio Backend |
|---|---|---|
| WindowsSpin | Windows (WPF) | NAudio / WASAPI |
# Build
dotnet build
# Run tests
dotnet test
# Pack NuGet package
dotnet pack src/Sendspin.SDK/Sendspin.SDK.csproj -c Releasemain— the trunk, and the 10.x line. PRs merge here. Pushes build and test but do not publish.release/*— maintained earlier lines (release/9.3). They take backports and never merge tomain.- Tags (
v*.*.*) — pushing a version tag triggers the publish to nuget.org and GitHub Packages via NuGet Trusted Publishing.
To release a new version:
git tag v10.0.0
git push origin v10.0.0MIT
Contributions welcome! Please open an issue or PR.