Sendspin.SDK
9.3.1
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
<PackageReference Include="Sendspin.SDK" Version="9.3.1" />
<PackageVersion Include="Sendspin.SDK" Version="9.3.1" />
<PackageReference Include="Sendspin.SDK" />
paket add Sendspin.SDK --version 9.3.1
#r "nuget: Sendspin.SDK, 9.3.1"
#:package Sendspin.SDK@9.3.1
#addin nuget:?package=Sendspin.SDK&version=9.3.1
#tool nuget:?package=Sendspin.SDK&version=9.3.1
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.
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
PublishAotand 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 capabilitiesSendspin.SDK.Connection- WebSocket connection managementSendspin.SDK.Protocol- Message types and serializationSendspin.SDK.Synchronization- Clock sync (Kalman filter)Sendspin.SDK.Audio- Pipeline, buffer, decoders, and sync correctionSendspin.SDK.Discovery- mDNS server discoverySendspin.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@v1follows 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 atTrace) rather than throwing, and a misbehavingVisualizationReceivedhandler 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
HardwareLatencyMsremoved - No action needed, latency handled automaticallyIAudioPipeline.SwitchDeviceAsync()required - Implement for device switchingIAudioPlayer.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 | Versions 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. |
-
net10.0
- Concentus (>= 2.2.2)
- Makaretu.Dns.Multicast (>= 0.27.0)
- Microsoft.Extensions.Logging.Abstractions (>= 8.0.2)
- Zeroconf (>= 3.7.16)
-
net8.0
- Concentus (>= 2.2.2)
- Makaretu.Dns.Multicast (>= 0.27.0)
- Microsoft.Extensions.Logging.Abstractions (>= 8.0.2)
- Zeroconf (>= 3.7.16)
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 |
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