Skip to content

About

Cross-platform .NET SDK for the Sendspin synchronized multi-room audio protocol

Resources

Stars

3 stars

Watchers

1 watching

Forks

Repository files navigation

sendspin-dotnet

NuGet Build License: MIT

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.

Features

  • 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@v1 role 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 PublishAot for single-file native executables
  • Cross-platform - Windows, Linux, macOS (.NET 8.0 / .NET 10.0)

Installation

dotnet add package Sendspin.SDK

Compatibility

The 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.

Security

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.TrustLevel reports Unpaired. Pairing establishes a per-server pre-shared key, and a session keyed with it reports Paired.
  • Unpaired access is off by default; leave it off unless you need it. ClientCapabilities.UnpairedAccessEnabled = true lets 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, implement ISendspinIdentityStore and IPairingRecordStore over 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.

Example

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.

Example Projects

Project Platform Audio Backend
WindowsSpin Windows (WPF) NAudio / WASAPI

Development

# Build
dotnet build

# Run tests
dotnet test

# Pack NuGet package
dotnet pack src/Sendspin.SDK/Sendspin.SDK.csproj -c Release

Branching & Releases

  • main — 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 to main.
  • 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.0

License

MIT

Contributing

Contributions welcome! Please open an issue or PR.

About

Cross-platform .NET SDK for the Sendspin synchronized multi-room audio protocol

Resources

Stars

3 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages