Sendspin.SDK 9.3.0

dotnet add package Sendspin.SDK --version 9.3.0
                    
NuGet\Install-Package Sendspin.SDK -Version 9.3.0
                    
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.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Sendspin.SDK" Version="9.3.0" />
                    
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.0
                    
#r "nuget: Sendspin.SDK, 9.3.0"
                    
#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.0
                    
#: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.0
                    
Install as a Cake Addin
#tool nuget:?package=Sendspin.SDK&version=9.3.0
                    
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.0 31 8/26/2026
9.2.0 248 8/8/2026
9.1.0 383 6/17/2026
9.0.6 112 6/17/2026
9.0.5 140 6/16/2026
9.0.4 151 6/15/2026
9.0.3 132 6/12/2026
9.0.2 125 6/12/2026
9.0.1 112 6/12/2026
9.0.0 154 6/5/2026
8.0.0 155 5/5/2026
7.4.0 120 4/17/2026
7.3.0 267 3/7/2026
7.2.1 136 3/4/2026
7.1.1 120 3/4/2026
7.1.0 115 3/4/2026
7.0.0 111 3/4/2026
6.3.6 121 3/4/2026
6.3.5 494 2/3/2026
Loading failed

v9.3.0 - Sync Conformance and Stability (9.x legacy line):

A curated backport for apps that cannot take 10.0's breaking renames. The public
API surface is unchanged from 9.2.0; every fix below is carried on internal
members where the upstream change added public ones.

SYNC CONFORMANCE - THESE CHANGE CORRECTION DEFAULTS:
- SyncCorrectionOptions.MaxSpeedCorrection defaults to 0.005 (0.5%), down from
 0.02 (2%). The spec caps effective playback speed at +/-0.5% (a MUST), and the
 cap is a fleet-homogeneity contract: every player in a group must recover from
 a disturbance at the same bounded rate. A larger configured value is now
 clamped where correction is applied, with a warning, rather than honoured.
- SyncCorrectionOptions.DeadbandMicroseconds defaults to 100 us, down from
 1_000. The old band sat exactly on the spec's +/-1 ms MUST floor, which makes
 the +/-0.5 ms SHOULD target unreachable by construction.
- New one-shot resynchronization tier above 5 ms: the excess is skipped or
 padded in a single discontinuity, which the spec describes and exempts from
 the speed cap. Grinding a 50 ms error out at 0.5% takes 10 seconds, during
 which the player audibly trails the rest of the group. Errors in that range
 now snap instead of being ground out - audible as one splice rather than as
 sustained misalignment.
- Clock synchronization matches the reference filter's constants and rules:
 reference process noise, non-positive round trips dropped rather than floored,
 T1/T4 stamped at the transport boundary, the reference probe cadence and
 timeout, and a converging-vs-steady burst interval keyed on convergence. A
 client on a good network now converges in seconds instead of twenty-odd.
- Decoder buffers are sized for the spec's 150 ms maximum chunk (PCM was sized
 for 50 ms, FLAC for a fixed 8192 frames). A chunk that still overruns decodes
 its legal prefix and reports the loss instead of truncating silently.
- ClientCapabilities.BufferCapacity is derived from the decoded buffer and the
 most compressed advertised codec instead of defaulting to a flat 32 MB. The
 spec lets a server fill toward whatever is advertised, so the old default
 licensed it to queue far more audio than the client could hold. A configured
 value above what the buffer can hold is clamped, with a warning.
- ITimedAudioBuffer.NotifyExternalCorrection is stats-only. It also moved the
 read cursor that ReadRaw had already advanced, so the reported sync error
 converged at twice the physical correction: the metric read near zero while
 the player stayed about half the drift out of the group.

STABILITY:
- The audio pipeline's start, stop, device switch and dispose are serialized.
 Interleaved, a track boundary could have one call's teardown dispose what
 another was still building, ending in Error and silence until the next stream.
- stream/start, stream/end and stream/clear take effect in the order they
 arrived, so an end's teardown can no longer land after the following start.
- The decoder reset requested by stream/clear is taken on the decoding thread
 rather than the requesting one, and a re-anchor no longer resets the decoder
 at all. The pipeline can no longer be left reporting Playing over an empty
 ring, which was permanent silence for the rest of the stream.
- A chunk arriving mid-start can no longer overtake the queued ones or decode
 through the pipeline's scratch buffer at the same time as one being drained.
- A peer message carrying JSON null where the protocol declares a value is
 rejected and closes the connection deliberately, instead of being half applied
 or handed to app event subscribers.

FORWARD COMPATIBILITY:
- server/command accepts set_output_delay and output_delay_ms alongside
 set_static_delay and static_delay_ms, so a client fielded now keeps working
 when a server adopts the rename. Outbound naming is unchanged.

----

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