Sendspin.SDK 9.3.1

There is a newer version of this package available.
See the version list below for details.
dotnet add package Sendspin.SDK --version 9.3.1
                    
NuGet\Install-Package Sendspin.SDK -Version 9.3.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Sendspin.SDK" Version="9.3.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Sendspin.SDK" Version="9.3.1" />
                    
Directory.Packages.props
<PackageReference Include="Sendspin.SDK" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Sendspin.SDK --version 9.3.1
                    
#r "nuget: Sendspin.SDK, 9.3.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Sendspin.SDK@9.3.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Sendspin.SDK&version=9.3.1
                    
Install as a Cake Addin
#tool nuget:?package=Sendspin.SDK&version=9.3.1
                    
Install as a Cake Tool

Sendspin SDK

A cross-platform .NET SDK for the Sendspin synchronized multi-room audio protocol. Build players that sync perfectly with Music Assistant and other Sendspin-compatible players.

NuGet GitHub

Features

  • Multi-room Audio Sync: Microsecond-precision clock synchronization using Kalman filtering
  • External Sync Correction (v5.0+): SDK reports sync error, your app applies correction
  • Platform Flexibility: Use playback rate, drop/insert, or hardware rate adjustment
  • Fast Startup: Audio plays within ~300ms of connection
  • Protocol Support: Full Sendspin WebSocket protocol implementation
  • Server Discovery: mDNS-based automatic server discovery
  • Audio Decoding: Built-in PCM, FLAC, and Opus codec support
  • Cross-Platform: Works on Windows, Linux, and macOS (.NET 8.0 / .NET 10.0)
  • NativeAOT & Trimming: Fully compatible with PublishAot and IL trimming for single-file native executables with no .NET runtime dependency
  • Audio Device Switching: Hot-switch audio output devices without interrupting playback

Installation

dotnet add package Sendspin.SDK

Quick Start

using Sendspin.SDK.Client;
using Sendspin.SDK.Connection;
using Sendspin.SDK.Synchronization;

// Create dependencies
var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole());
var connection = new SendspinConnection(loggerFactory.CreateLogger<SendspinConnection>());
var clockSync = new KalmanClockSynchronizer(loggerFactory.CreateLogger<KalmanClockSynchronizer>());

// Create client with device info
var capabilities = new ClientCapabilities
{
    ClientName = "My Player",
    ProductName = "My Awesome Player",
    Manufacturer = "My Company",
    SoftwareVersion = "1.0.0"
};

var client = new SendspinClientService(
    loggerFactory.CreateLogger<SendspinClientService>(),
    connection,
    clockSync,
    capabilities
);

// Connect to server
await client.ConnectAsync(new Uri("ws://192.168.1.100:8927/sendspin"));

// Handle events
client.GroupStateChanged += (sender, group) =>
{
    Console.WriteLine($"Now playing: {group.Metadata?.Title}");
};

// Send commands
await client.SendCommandAsync("play");
await client.SetVolumeAsync(75);

Architecture

┌─────────────────────────────────────────────────────────────────┐
│                     Your Application                            │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │  SyncCorrectionCalculator  │  Your Resampler/Drop Logic │   │
│  │  (correction decisions)    │  (applies correction)      │   │
│  └─────────────────────────────────────────────────────────┘   │
├─────────────────────────────────────────────────────────────────┤
│  SendspinClientService    │  AudioPipeline    │  IAudioPlayer   │
│  (protocol handling)      │  (orchestration)  │  (your impl)    │
├─────────────────────────────────────────────────────────────────┤
│  SendspinConnection  │  KalmanClockSync  │  TimedAudioBuffer    │
│  (WebSocket)         │  (timing)         │  (reports error)     │
├─────────────────────────────────────────────────────────────────┤
│  OpusDecoder  │  FlacDecoder  │  PcmDecoder                     │
└─────────────────────────────────────────────────────────────────┘

Namespaces:

  • Sendspin.SDK.Client - Client services and capabilities
  • Sendspin.SDK.Connection - WebSocket connection management
  • Sendspin.SDK.Protocol - Message types and serialization
  • Sendspin.SDK.Synchronization - Clock sync (Kalman filter)
  • Sendspin.SDK.Audio - Pipeline, buffer, decoders, and sync correction
  • Sendspin.SDK.Discovery - mDNS server discovery
  • Sendspin.SDK.Models - Data models (GroupState, TrackMetadata)

Sync Correction System (v5.0+)

Starting with v5.0.0, sync correction is external - the SDK reports sync error and your application decides how to correct it. This enables platform-specific correction strategies:

  • Windows: WDL resampler, SoundTouch, or drop/insert
  • Browser: Native playbackRate (WSOLA time-stretching)
  • Linux: ALSA hardware rate adjustment, PipeWire rate
  • Embedded: Platform-specific DSP

How It Works

SDK (reports error only)              App (applies correction)
────────────────────────────────────────────────────────────────
TimedAudioBuffer                      SyncCorrectionCalculator
├─ ReadRaw() - no correction          ├─ UpdateFromSyncError()
├─ SyncErrorMicroseconds              ├─ DropEveryNFrames
├─ SmoothedSyncErrorMicroseconds      ├─ InsertEveryNFrames
└─ NotifyExternalCorrection()         └─ TargetPlaybackRate

Tiered Correction Strategy

The SyncCorrectionCalculator implements the same tiered strategy as the reference CLI:

Sync Error Correction Method Description
< 1ms None (deadband) Error too small to matter
1-15ms Playback rate adjustment Smooth resampling (imperceptible)
15-500ms Frame drop/insert Faster correction for larger drift
> 500ms Re-anchor Clear buffer and restart sync

Usage Example

using Sendspin.SDK.Audio;

// Create the correction calculator
var correctionProvider = new SyncCorrectionCalculator(
    SyncCorrectionOptions.Default,  // or SyncCorrectionOptions.CliDefaults
    sampleRate: 48000,
    channels: 2
);

// Subscribe to correction changes
correctionProvider.CorrectionChanged += provider =>
{
    // Update your resampler rate
    myResampler.Rate = provider.TargetPlaybackRate;

    // Or handle drop/insert
    if (provider.CurrentMode == SyncCorrectionMode.Dropping)
    {
        dropEveryN = provider.DropEveryNFrames;
    }
};

// In your audio callback:
public int Read(float[] buffer, int offset, int count)
{
    // Read raw samples (no internal correction)
    int read = timedAudioBuffer.ReadRaw(buffer, offset, count, currentTimeMicroseconds);

    // Update correction provider with current error
    correctionProvider.UpdateFromSyncError(
        timedAudioBuffer.SyncErrorMicroseconds,
        timedAudioBuffer.SmoothedSyncErrorMicroseconds
    );

    // Apply your correction strategy...
    // If dropping/inserting, notify the buffer:
    timedAudioBuffer.NotifyExternalCorrection(samplesDropped, samplesInserted);

    return outputCount;
}

Configuring Sync Behavior

The correction caps are spec conformance points, not tuning knobs. The effective playback speed must stay within ±0.5% of normal (a protocol MUST), and the steady-state error must stay within ±1 ms, so the dead band sits an order of magnitude below that at 100 µs. Both defaults match the reference players this SDK shares a group with. Setting MaxSpeedCorrection above 0.5% does not raise the cap — the value is clamped where correction is applied and a warning is logged once. Errors too large to close inside the cap are handled by a one-shot resynchronization that the spec exempts from it, not by exceeding it.

// Use default settings (spec caps: 0.5% max, 100 us dead band, 3s target)
var options = SyncCorrectionOptions.Default;

// Use CLI-compatible settings (faster convergence, same caps)
var options = SyncCorrectionOptions.CliDefaults;

// Custom options
var options = new SyncCorrectionOptions
{
    CorrectionTargetSeconds = 2.0,                // Time to eliminate drift
    ResamplingThresholdMicroseconds = 15_000,     // Resampling vs drop/insert
    ReanchorThresholdMicroseconds = 500_000,      // Clear buffer threshold
    StartupGracePeriodMicroseconds = 500_000,     // No correction during startup
};

var calculator = new SyncCorrectionCalculator(options, sampleRate, channels);

Advertised buffer capacity

ClientCapabilities.BufferCapacity is derived from the SDK's decoded-buffer duration and the formats you advertise, rather than defaulting to a flat 32 MB. The spec makes this a hard per-player byte limit the server may fill toward, so an over-advertisement is audio the server legally sends and the client discards before playing it. A value set explicitly is honoured only up to what the buffer can actually hold; anything larger is clamped and reported at client/hello time.

Platform-Specific Audio

The SDK handles decoding, buffering, and sync error reporting. You implement IAudioPlayer for audio output:

public class MyAudioPlayer : IAudioPlayer
{
    public long OutputLatencyMicroseconds { get; private set; }

    public Task InitializeAsync(AudioFormat format, CancellationToken ct)
    {
        // Initialize your audio backend (WASAPI, PulseAudio, CoreAudio, etc.)
    }

    public int Read(float[] buffer, int offset, int count)
    {
        // Called by audio thread - read from TimedAudioBuffer.ReadRaw()
        // Apply sync correction externally
    }

    // ... other methods
}

Platform suggestions:

  • Windows: NAudio with WASAPI (WasapiOut)
  • Linux: OpenAL, PulseAudio, or PipeWire
  • macOS: AudioToolbox or AVAudioEngine
  • Cross-platform: SDL2

Server Discovery

Automatically discover Sendspin servers on your network:

var discovery = new MdnsServerDiscovery(logger);
discovery.ServerDiscovered += (sender, server) =>
{
    Console.WriteLine($"Found: {server.Name} at {server.Uri}");
};
await discovery.StartAsync();

Device Info

Identify your player to servers:

var capabilities = new ClientCapabilities
{
    ClientName = "Living Room",              // Display name
    ProductName = "MySpeaker Pro",           // Product identifier
    Manufacturer = "Acme Audio",             // Your company
    SoftwareVersion = "2.1.0",               // App version
    MacAddress = "aa:bb:cc:dd:ee:ff"         // NIC MAC, lowercase colon-separated
};

All fields are optional and omitted from the protocol if null.

Player Timing & Static Delay

Players report timing requirements so the server can schedule audio far enough ahead to avoid buffer underruns and start-of-stream truncation (per the Sendspin spec's player timing capabilities). These are advertised in every client/state message:

var capabilities = new ClientCapabilities
{
    // Minimum startup lead time: codec init, decode warmup, backend buffering, DAC latency.
    // The server schedules the first chunk at least this far ahead after a stream start/restart.
    RequiredLeadTimeMs = 200,   // default: 200 ms (conservative LAN starting point)

    // Minimum ongoing buffer to absorb network jitter (primarily for live streams).
    MinBufferMs = 150,          // default: 150 ms

    // Whether to accept the server's set_static_delay command (advertised in client/state).
    SupportsSetStaticDelay = true,
};

Report the lowest values that reliably avoid truncation/underruns for your device and network — larger for remote or high-latency links, smaller for stable LAN. Do not fold static_delay_ms into these values; the server applies static delay separately. For empirical tuning, the audio pipeline exposes measured latency (e.g. AudioPipeline.DetectedOutputLatencyMs).

If conditions change at runtime (e.g. a link-type change, or a measured lead time after warmup), update the values and the SDK re-reports client/state:

await client.UpdateTimingAsync(requiredLeadTimeMs: 120, minBufferMs: 80);

Debounce these updates yourself — report only sustained changes, not transient fluctuations.

Persisting static delay across restarts

static_delay_ms compensates for hardware delay beyond the audio port (external speakers, amplifiers) and must persist across reboots and reconnections. Because the SDK is a library and cannot choose where to store it, implement IStaticDelayStore and pass it to the client. The SDK loads on connect (before the first client/state) and saves whenever the delay changes (via a set_static_delay command or a GroupSync offset):

public sealed class FileStaticDelayStore : IStaticDelayStore
{
    // Use InvariantCulture so the value round-trips regardless of the host's locale.
    public double? Load() => File.Exists(path)
        ? double.Parse(File.ReadAllText(path), CultureInfo.InvariantCulture)
        : null;

    public void Save(double staticDelayMs)
        => File.WriteAllText(path, staticDelayMs.ToString(CultureInfo.InvariantCulture));
}

var client = new SendspinClientService(
    logger, connection, clockSync, capabilities,
    audioPipeline: pipeline,
    staticDelayStore: new FileStaticDelayStore());

When no store is supplied, behavior is unchanged: the embedder re-supplies the delay on each connect.

Artwork

Artwork clients support 1–4 independent channels (e.g. album art on one display, artist photos on another). Each channel has its own source, format, and maximum size. Configure them in capabilities:

var capabilities = new ClientCapabilities
{
    ArtworkChannels = new()
    {
        new() { Source = ArtworkSources.Album,  Format = "jpeg", MediaWidth = 512, MediaHeight = 512 }, // channel 0
        new() { Source = ArtworkSources.Artist, Format = "png",  MediaWidth = 256, MediaHeight = 256 }, // channel 1
    }
};

Images arrive per channel, with the display timestamp and channel number:

client.ArtworkReceived += (_, e) =>
{
    // e.Channel (0-3), e.Timestamp (server clock, microseconds), e.ImageData (jpeg/png/bmp bytes)
    displays[e.Channel].Show(e.ImageData);
};

client.ArtworkCleared += (_, e) => displays[e.Channel].Clear(); // empty binary message = clear that channel

Change or disable a channel at runtime without reconnecting (server replies with a new stream/start):

// Switch channel 1 to artist art at a new size:
await client.RequestArtworkFormatAsync(channel: 1, source: ArtworkSources.Artist, mediaWidth: 400, mediaHeight: 400);

// Disable channel 1 (server stops sending it); re-enable later by requesting a real source again:
await client.RequestArtworkFormatAsync(channel: 1, source: ArtworkSources.None);

Color

Clients with the color role receive a palette derived from the current audio — useful for ambient lighting, screen backgrounds, or UI theming. Colors arrive via server/state and are merged onto GroupState.Colors; subscribe to ColorChanged to react:

client.ColorChanged += (_, palette) =>
{
    // RgbColor? per role; null until the server provides it (or after it clears it).
    if (palette.BackgroundDark is { } bg) lights.SetBackground(bg.R, bg.G, bg.B);
    if (palette.Primary is { } primary) ui.Accent = primary;
};

Available colors: BackgroundDark, BackgroundLight, Primary, Accent, OnDark, OnLight, plus a Timestamp (server clock, µs). The server guarantees WCAG 4.5:1 contrast ratios between the background/on-color pairs — clients use the values directly and do no contrast math.

Updates are deltas: a color absent from an update is left unchanged, an explicit null clears it, and a value updates it. The role is enabled by default (color@v1 in ClientCapabilities.Roles); remove it to opt out.

Visualizer

Clients with the visualizer@v1 role receive real-time audio features for music visualization. Six feature types are available: loudness, f_peak (dominant frequency + amplitude), spectrum (display-binned FFT), beat, peak (energy onsets), and pitch. The role is opt-in — set VisualizerSupport and add visualizer@v1 to Roles:

var capabilities = new ClientCapabilities
{
    Roles = { "player@v1", "visualizer@v1" },
    VisualizerSupport = new VisualizerSupport
    {
        BufferCapacity = 65536,
        RateMax = 30, // max frames/sec
        Types = new() { VisualizerTypes.Loudness, VisualizerTypes.Spectrum, VisualizerTypes.Beat },
        // Required when Spectrum is requested:
        Spectrum = new VisualizerSpectrum { NDispBins = 32, Scale = "log", FMin = 20, FMax = 16000 },
    },
};

Each binary message carries one feature type; subscribe to VisualizationReceived and read the populated field:

client.VisualizationReceived += (_, frame) =>
{
    if (frame.Loudness is { } loud)      meter.Level = loud / 65535.0;
    if (frame.Spectrum is { } bins)      bars.Update(bins);          // NDispBins values
    if (frame.IsDownbeat is { } down)    pulse.Beat(strong: down);
    if (frame.PitchMidi is { } note)     label.Text = $"MIDI {note:F1}"; // pitch is Q8.8 → fractional MIDI
};

Spectrum frames are validated against the negotiated NDispBins from the latest stream/start; malformed frames are dropped (no event). Renegotiate at runtime with RequestVisualizerFormatAsync(...).

Note: visualizer@v1 follows the aiosendspin reference implementation, which is ahead of the formal protocol spec. The wire format may still evolve. The role degrades gracefully while it matures: it is opt-in (off by default), frames that don't match the negotiated/expected format are dropped (logged at Trace) rather than throwing, and a misbehaving VisualizationReceived handler is isolated so it can't disrupt audio or artwork.

NativeAOT Support

Since v7.0.0, the SDK is fully compatible with NativeAOT deployment and IL trimming. This means you can publish your Sendspin player as a single native executable with no .NET runtime dependency — ideal for embedded devices, containers, or minimal Linux installations.

<PropertyGroup>
  <PublishAot>true</PublishAot>
</PropertyGroup>
dotnet publish -c Release -r linux-x64
# Produces a single native binary (~15-25MB depending on dependencies)

How it works: The SDK uses source-generated System.Text.Json serialization (no runtime reflection) and built-in .NET WebSocket APIs. All public types are annotated with IsAotCompatible and IsTrimmable to ensure the .NET build analyzers catch any regressions.

Your code: If your IAudioPlayer implementation also avoids reflection, the entire stack will be AOT-safe. Most audio libraries (SDL2, OpenAL, PipeWire bindings) work fine with NativeAOT.

Migration Guide

Upgrading to v7.0.0

Breaking change: SendspinListener.ServerConnected event parameter type changed.

// Before (v6.x):
listener.ServerConnected += (sender, fleckConnection) => { /* Fleck.IWebSocketConnection */ };

// After (v7.0+):
listener.ServerConnected += (sender, wsConnection) => { /* WebSocketClientConnection */ };

No changes needed if you only use SendspinHostService or SendspinClientService (most consumers).

Upgrading to v5.0.0

Breaking change: Sync correction is now external. The SDK reports error; you apply correction.

Before (v4.x and earlier):

// SDK applied correction internally
var read = buffer.Read(samples, currentTime);
buffer.TargetPlaybackRateChanged += rate => resampler.Rate = rate;

After (v5.0+):

// Create correction provider
var correctionProvider = new SyncCorrectionCalculator(
    SyncCorrectionOptions.Default, sampleRate, channels);

// Read raw samples (no internal correction)
var read = buffer.ReadRaw(samples, offset, count, currentTime);

// Update and apply correction externally
correctionProvider.UpdateFromSyncError(
    buffer.SyncErrorMicroseconds,
    buffer.SmoothedSyncErrorMicroseconds);

// Subscribe to rate changes
correctionProvider.CorrectionChanged += p => resampler.Rate = p.TargetPlaybackRate;

// Notify buffer of any drops/inserts for accurate tracking
buffer.NotifyExternalCorrection(droppedCount, insertedCount);

Benefits:

  • Browser apps can use native playbackRate (WSOLA)
  • Windows apps can choose WDL resampler, SoundTouch, or drop/insert
  • Linux apps can use ALSA hardware rate adjustment
  • Testability: correction logic is isolated

Upgrading to v3.0.0

Breaking change: IClockSynchronizer requires HasMinimalSync property.

// Add to custom IClockSynchronizer implementations:
public bool HasMinimalSync => MeasurementCount >= 2;

Upgrading to v2.0.0

  1. HardwareLatencyMs removed - No action needed, latency handled automatically
  2. IAudioPipeline.SwitchDeviceAsync() required - Implement for device switching
  3. IAudioPlayer.SwitchDeviceAsync() required - Implement in your audio player

Example Projects

See the Windows client for a complete WPF implementation using NAudio/WASAPI with external sync correction.

License

MIT License - see LICENSE for details.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
9.3.2 264 8/31/2026
9.3.1 63 8/31/2026
9.3.0 106 8/26/2026
9.2.0 253 8/8/2026
9.1.0 434 6/17/2026
9.0.6 117 6/17/2026
9.0.5 144 6/16/2026
9.0.4 155 6/15/2026
9.0.3 136 6/12/2026
9.0.2 129 6/12/2026
9.0.1 117 6/12/2026
9.0.0 157 6/5/2026
8.0.0 157 5/5/2026
7.4.0 124 4/17/2026
7.3.0 270 3/7/2026
7.2.1 140 3/4/2026
7.1.1 124 3/4/2026
7.1.0 116 3/4/2026
7.0.0 115 3/4/2026
Loading failed

v9.3.1 - Playback Stability (9.x legacy line):

Two fixes for defects that could stop or degrade playback. One additive public
API: SendspinHostService gains AdoptClientInitiated/ReleaseClientInitiated,
which the second fix requires. Everything else is internal.

- The one-shot hard-sync tier stands down when snapping is not closing the
 error (#252). The tier gains the cooldown and convergence check its
 neighbours already had: a snap is not eligible again until its own duration
 has elapsed, and three consecutive snaps that leave the error where they
 found it stand the tier down in favour of the capped +/-0.5% continuous
 tier. Previously a constant error the splice could not move - the usual
 cause is a misreported output latency - re-fired the tier as fast as snaps
 drained, splicing silence indefinitely while buffer depth grew without
 bound. AudioBufferStats reports the stand-down, and it lifts as soon as the
 error leaves the snap tier's band.
- A rejected server-initiated connection no longer tears down the active
 client-initiated session (#253). The handshake tail stops when the
 application refuses the connection inside its own completion, so the shared
 clock synchronizer and audio pipeline are no longer reset against the
 session that is still playing. SendspinHostService.AdoptClientInitiated
 lets the application register the session it dialled, so an incoming server
 now loses arbitration at the door - previously it was accepted as "no
 existing connection" because a dialled session was invisible to the host.
 An adopted session is owned by the application: the host never disconnects,
 reports, or displaces it.

----

     Earlier release notes: https://github.com/Sendspin/sendspin-dotnet/releases