Sendspin.SDK 9.1.0

dotnet add package Sendspin.SDK --version 9.1.0
                    
NuGet\Install-Package Sendspin.SDK -Version 9.1.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.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Sendspin.SDK" Version="9.1.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.1.0
                    
#r "nuget: Sendspin.SDK, 9.1.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.1.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.1.0
                    
Install as a Cake Addin
#tool nuget:?package=Sendspin.SDK&version=9.1.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

// Use default settings (conservative: 2% max, 3s target)
var options = SyncCorrectionOptions.Default;

// Use CLI-compatible settings (aggressive: 4% max, 2s target)
var options = SyncCorrectionOptions.CliDefaults;

// Custom options
var options = new SyncCorrectionOptions
{
    MaxSpeedCorrection = 0.04,                    // 4% max rate adjustment
    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);

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.1.0 221 6/17/2026
9.0.6 105 6/17/2026
9.0.5 134 6/16/2026
9.0.4 139 6/15/2026
9.0.3 119 6/12/2026
9.0.2 118 6/12/2026
9.0.1 103 6/12/2026
9.0.0 137 6/5/2026
8.0.0 146 5/5/2026
7.4.0 114 4/17/2026
7.3.0 261 3/7/2026
7.2.1 132 3/4/2026
7.1.1 115 3/4/2026
7.1.0 111 3/4/2026
7.0.0 109 3/4/2026
6.3.6 111 3/4/2026
6.3.5 481 2/3/2026
6.3.4 129 2/3/2026
6.3.3 126 2/3/2026
Loading failed

v9.0.1 - Metadata null-clear fix:

Fixes artwork URL and all metadata string/numeric fields not being cleared when
the server sends an explicit null. Music Assistant sends artwork_url: null when
transitioning to an artless track, and clears all fields via cleared_update()
when playback stops. The SDK was using ?? merge semantics which treated explicit
null identically to absent — silently retaining the previous value in both cases.

Extended the existing Optional<T> pattern from the progress field to all
ServerMetadata fields (title, artist, album_artist, album, artwork_url, year,
track, timestamp). Absent = keep existing, explicit null = clear. This matches
aiosendspin's UndefinedField on every metadata field and fixes the three-layer
artwork persistence bug observed in the Windows client.

----

v9.0.0 - Full Role Coverage and Spec Conformance:

This release completes every Sendspin protocol role and closes a cross-SDK
conformance audit. All six roles are now implemented: player, controller,
metadata, artwork, color, and visualizer.

NEW ROLES AND FEATURES:
- Color role (color@v1): receives the audio-derived palette on GroupState.Colors
 with a ColorChanged event (RgbColor, spec delta semantics).
- Visualizer role (visualizer@v1): six feature types (loudness, f_peak, spectrum,
 beat, peak, pitch) via VisualizationReceived; opt-in. Mirrors the aiosendspin
 reference, which is ahead of the formal spec and may still evolve.
- Artwork role completed: 1-4 independent channels, channel/timestamp-aware
 events, stream/request-format, source "none".
- Controller role completed: GroupState.SupportedCommands, SetMuteAsync.
- Player timing capabilities: required_lead_time_ms, min_buffer_ms, the
 set_static_delay command, and IStaticDelayStore for cross-restart persistence.
- repeat/shuffle now read from the controller state object; mac_address in
 device_info; perceptual volume curve; client/state error/synchronized on
 pipeline error/recovery; player/artwork/visualizer stream/request-format emit;
 delta client/state; external_source enter/exit API.

BREAKING CHANGES:
- Removed the non-spec client/sync_offset message and the SyncOffsetApplied
 event / SyncOffsetEventArgs. Use the server/command set_static_delay path.
- ArtworkReceived is now EventHandler<ArtworkReceivedEventArgs> and
 ArtworkCleared is EventHandler<ArtworkClearedEventArgs> (both carry the
 channel and timestamp).
- ClientCapabilities.ArtworkFormats / ArtworkMaxSize removed in favor of
 ArtworkChannels (1-4 per-channel specs).
- StreamRequestFormatMessage reshaped to the spec payload envelope.
- repeat/shuffle removed from the TrackMetadata model (now on GroupState).
- The default client/goodbye reason changed from "user_request" to "restart"
 so an unexplained disconnect is reconnectable; pass an explicit reason to
 opt out.

----

v8.0.0 - Time-Filter Spec Conformance:

This release brings KalmanClockSynchronizer into alignment with the upstream
Sendspin time-filter reference (github.com/Sendspin/time-filter) and the
protocol spec for static_delay_ms. Six audit findings worth of correctness
fixes against PR #5 (drift SNR gate), PR #6 (measurement-variance defaults),
the spec's static_delay_ms convention, and the README's recommended sequential
burst pattern.

BREAKING CHANGES:

KalmanClockSynchronizer constructor:
- `measurementNoise` renamed to `measurementNoiseFloor`; default changed
 from 10000 µs² to 0 µs² to match upstream. The previous floor had no
 equivalent in the reference and over-inflated R on low-RTT paths,
 preventing the filter from converging tighter than ~32 µs uncertainty
 on localhost / loopback / USB even with 100+ measurements.
- `forgetFactor` default changed from 1.0 to 2.0. The previous 1.0 produced
 a forget_variance_factor of 1.0, which silently disabled adaptive
 forgetting in every default-constructed instance (the internal gate
 `_forgetVarianceFactor > 1.0` was permanently false). Network-disruption
 recovery never fired.
- `adaptiveCutoff` default changed from 0.75 to 3.0 AND its semantic
 changed: the threshold is now `cutoff × max_error` (RTT-based, matches
 upstream cpp:99) instead of `cutoff × √(predicted_variance)` (Kalman-
 state-based). Consumers passing this value explicitly will see
 substantially different forgetting behavior. Reset to 3.0 unless you have
 a specific tuning reason.
- New `maxErrorScale` parameter (default 0.5). max_error is a worst-case
 bound (RTT/2), not a 1σ estimate; scaling before squaring it into R
 matches upstream PR #6.
- New `driftSignificanceThreshold` parameter (default 2.0). Mirrors
 upstream's drift_significance_threshold from PR #5.

ServerToClientTime sign convention (static_delay_ms):
- The value held in `StaticDelayMs` is now SUBTRACTED from the converted
 client time (was added). Per the Sendspin spec and every reference
 implementation (sendspin-cpp, SendspinKit, sendspin-js, aiosendspin),
 positive static_delay_ms compensates for downstream hardware delay by
 scheduling audio earlier from the digital pipeline.
- The previous SDK behavior produced approximately 2× the configured
 delay's worth of error against a spec-conformant server: the SDK
 applied the value with the wrong sign locally AND sent the same value
 over the wire in client/state for the server to compensate. Both halves
 pulled in the wrong direction.
- Consumers using non-zero `StaticDelayMs` through a UI slider need to
 flip their sign convention: positive means "play earlier" / "compensate
 hardware delay" now.

`ClientSyncOffsetPayload.OffsetMs` and `SyncOffsetEventArgs.OffsetMs`:
- Documentation aligned with the new spec convention. The wire format is
 unchanged; only the semantic of positive vs negative values flipped to
 match StaticDelayMs.

ALGORITHMIC FIXES:

- Drift compensation gate now uses an SNR / z-score test
 (|drift| > k × σ_drift, default k = 2.0) instead of an absolute
 uncertainty threshold (σ_drift < 50 µs/s). The previous gate could apply
 a drift correction whose uncertainty exceeded its magnitude, making
 timestamps actively worse than no compensation. Ports upstream PR #5.
- Two-stage initialization: the second measurement now bootstraps drift
 via finite differences (drift = (z1 - z0) / dt) and propagates the two
 measurement variances into drift_variance = (R0 + R1) / dt². The
 previous code went straight to the standard Kalman update with a 1e6
 drift_variance prior, producing a drift gain of ~1e-6 and effectively
 zero drift estimate after two measurements. Matches upstream cpp:53-75.
- Adaptive forgetting threshold is now RTT-aware (3 × max_error). The
 previous Kalman-state-based threshold (0.75 × √variance) could fire on
 microsecond-scale jitter once the filter converged.
- max_error floored to 1µs to prevent zero-variance NaN cascades on
 localhost (RTT = 0) and pathological clock-skew (RTT < 0). Matches the
 SwiftKit port's clamp.
- Covariance variance floor lowered from arbitrary 1.0 / 0.01 µs² to a
 uniform 1e-6 µs². The simplified Kalman covariance update's underflow
 recovery is now effectively invisible against measurement noise instead
 of causing observable state discontinuities.

TIME-SYNC BURST LOOP:

- Probes are now sent sequentially: each client/time message is awaited
 individually with a 2-second per-probe timeout before the next is sent.
 The previous fire-and-forget pattern shared a TCP send window across all
 8 probes, correlating their RTTs via head-of-line blocking and defeating
 the burst's purpose ("find the cleanest sample"). Matches the JS
 reference player and sendspin-cpp.
- Concurrent-burst guard: the continuous time-sync loop and the smart-sync
 trigger in HandleStreamStart can no longer wedge each other's
 TaskCompletionSource. Interlocked.CompareExchange flag matches JS's
 timeSyncBurstActive guard.
- Late / unmatched server/time responses are now discarded rather than
 fed to ProcessMeasurement directly. Prevents a slow sample from
 polluting burst-best selection.

OTHER:

- `KalmanClockSynchronizer.StaticDelayMs` getter and setter are now lock-
 protected for consistency with the other public properties on the class.
- `KalmanClockSynchronizer.SendTimeSyncBurstAsync` is now `internal`
 (visible only to the test assembly via InternalsVisibleTo) for direct
 invocation in regression tests.

TEST COVERAGE:

14 new xUnit tests added (now 85 total, up from 71): drift SNR gate (4),
measurement-variance + adaptive forgetting + localhost (3), two-stage
initialization (3), static_delay_ms sign convention (3), and time-sync
burst concurrent-call guard plus unmatched-response discard (2).

Migration:

If you construct KalmanClockSynchronizer with default arguments and never
set StaticDelayMs, no migration is required — every default behavior is
either identical or strictly more conformant.

If you set StaticDelayMs to a non-zero value through a UI slider:
- Flip the sign convention in your binding. A slider value that previously
 produced "play later" behavior under v7.x will produce "play earlier"
 under v8.x. Add a migration step to negate persisted values once on
 upgrade if you want to preserve user intent.

If you pass `adaptiveCutoff` or `forgetFactor` explicitly:
- Reset to defaults (3.0 and 2.0) unless you have specific tuning reasons.
 The semantic of adaptiveCutoff changed from σ-multiple to max_error-multiple.

If you implement IClockSynchronizer directly (rather than using
KalmanClockSynchronizer):
- No interface signature changes. The XML doc on StaticDelayMs has been
 updated to reflect the spec convention; review your implementation if
 you previously inverted the sign internally.

v7.4.0 - Typed Protocol Event Surface:

New Features:
- ISendspinClient.ServerHelloReceived event and LastServerHello property. Exposes the full
 server/hello payload (ServerId, Name, Version, ActiveRoles, ConnectionReason) so apps don't
 have to subscribe to the raw TextMessageReceived event and re-parse JSON.
- ISendspinClient.StreamStartReceived event and LastStreamStart property. Fires for every
 stream/start frame (audio, artwork, or both) with the fully deserialized payload.
- SendspinHostService.ServerHelloReceived and SendspinHostService.StreamStartReceived forward
 the corresponding per-client events to host-service consumers.
- StreamStartPayload.Artwork: models the previously-dropped "artwork" sub-object sent by servers
 alongside or instead of "player". Exposes channel source, format, width, and height.
- Addresses Sendspin/conformance#39: the .NET conformance adapter no longer needs to
 JsonDocument.Parse raw protocol frames to satisfy the peer_hello / stream contracts.

No breaking changes. Existing TextMessageReceived, scalar ServerId/ServerName/ConnectionReason
properties, and all pre-existing events behave identically.

v7.2.1 - Buffer Overrun Sync Fix:

Bug Fixes:
- Fixed multi-player sync catastrophe where buffer overruns during the server's initial
 audio burst destroyed the beginning of the stream. Compact codecs (OPUS) can burst 40+
 seconds of audio in seconds on LAN, causing 1700+ overruns that advanced the player
 20-35 seconds into the song while other players started from the beginning.
- Pre-playback overrun safety net: When the buffer fills before playback starts, incoming
 audio is now discarded instead of dropping the oldest. This preserves the stream's
 starting position so all players begin at the same point in the song.
- SkipStaleAudio: When a player's pipeline initializes late and the scheduled start time
 is already in the past, the buffer now skips forward through stale segments to the
 correct playback position. Previously, the player would start from the beginning of
 the buffer regardless of timestamps, causing permanent offset vs other players.
- Updated ClientCapabilities.BufferCapacity doc: Clarified that apps should derive this
 from actual PCM buffer duration and worst-case codec bitrate instead of using the
 hardcoded 32MB default.

v7.2.0 - Reconnect Resilience, Freeze/Thaw Kalman State, FLAC Fixes:

New Features:
- IClockSynchronizer.Freeze()/Thaw()/IsFrozen: Snapshot and restore Kalman filter state
 across reconnects. On disconnect, Freeze() saves converged offset/drift estimates.
 On reconnect, Thaw() restores them with 10x inflated covariance for fast adaptation.
 Converges in 1-2 measurements instead of 5+ from scratch. Matches Android client.
- Reconnect stabilization period: Suppresses sync corrections for 2 seconds after
 WebSocket reconnect while the Kalman filter re-converges. Prevents audible artifacts
 from unreliable sync error measurements. Matches Android client behavior.
- SyncCorrectionOptions.ReconnectStabilizationMicroseconds: Configurable duration
 (default: 2,000,000 = 2 seconds)
- Reanchor cooldown: 5-second minimum between re-anchors, matching Android/Python CLI.
 Prevents re-anchor loops on track change.
- SyncCorrectionOptions.ReanchorCooldownMicroseconds: Configurable cooldown duration
 (default: 5,000,000 = 5 seconds)
- ISyncCorrectionProvider.NotifyReconnect(): Default interface method for reconnect notification
- IAudioPlayer.NotifyReconnect(): Default interface method for player reconnect notification
- IAudioPipeline.NotifyReconnect(): Pipeline-level reconnect notification
- ITimedAudioBuffer.NotifyReconnect(): Buffer-level reconnect notification

Bug Fixes:
- Fixed FLAC 32-bit silence: Use actual STREAMINFO bit depth instead of stream/start header.
 The stream/start message may report incorrect bit depth for FLAC streams; the decoder now
 reads the true bit depth from the FLAC STREAMINFO metadata block.
- Fixed AudioFormat.BitDepth not updating from decoded FLAC STREAMINFO.
- Fixed artwork-only stream/start causing unnecessary pipeline start. stream/start messages
 with no "player" key are now correctly skipped.
- Increased default buffer capacity from 8s to 30s to prevent overruns during server's
 initial audio burst on track start.

BREAKING (minor):
- IClockSynchronizer: Added Freeze(), Thaw(), IsFrozen members.
 If you implement IClockSynchronizer directly (not using KalmanClockSynchronizer), add these.
- ITimedAudioBuffer: Added NotifyReconnect() method (no default implementation).
 If you implement ITimedAudioBuffer directly (not using TimedAudioBuffer), add this method.
- Most consumers are unaffected as the built-in implementations are the only known ones.

v7.1.1 - Documentation:
- Added NativeAOT support section to README with PublishAot usage and guidance
- Added v7.0.0 migration guide for SendspinListener.ServerConnected breaking change
- Listed NativeAOT & trimming compatibility in Features section

v7.1.0 - Protocol Compliance (aiosendspin audit):

New Features:
- PlayerStatePayload.StaticDelayMs: Reports configured static delay to server
 for GroupSync calibration. Server uses this to compensate for device output
 latency when calculating group-wide sync offsets.
- ISendspinClient.ArtworkCleared event: Raised when server sends empty artwork
 binary payload (type 8-11, 0-byte data) to signal "no artwork available".
 Previously, empty payloads were passed through as empty byte arrays.
- SendPlayerStateAsync now accepts optional staticDelayMs parameter (default 0.0)
- SendspinHostService.ArtworkCleared event: Forwarded from child clients

Bug Fixes:
- Empty artwork binary messages no longer treated as valid image data

v7.0.0 - NativeAOT Support:

BREAKING CHANGES:
- SendspinListener.ServerConnected event: parameter type changed from
 Fleck.IWebSocketConnection to Sendspin.SDK.Connection.WebSocketClientConnection
- Fleck NuGet dependency removed (replaced with built-in .NET WebSocket APIs)

New Features:
- Full NativeAOT compatibility: SDK can be used in NativeAOT-published applications
- JSON source generation: System.Text.Json uses compile-time source generation
 instead of runtime reflection (also improves startup performance)
- Built-in WebSocket server: TcpListener + WebSocket.CreateFromStream() replaces
 Fleck for server-initiated connections (no admin privileges, fully AOT-safe)
- IsAotCompatible and IsTrimmable properties enabled

Migration:
- If you subscribe to SendspinListener.ServerConnected directly, update the event
 handler parameter type from IWebSocketConnection to WebSocketClientConnection
- If you reference Fleck types through the SDK, switch to WebSocketClientConnection
- No changes needed if you only use SendspinHostService or SendspinClient

v6.3.4 - AudioBufferStats.TimingSourceName Fix:
- Fixed: AudioBufferStats.TimingSourceName property now included in GetStats() output
- Enables "Stats for Nerds" UI to display active timing source (audio-clock, monotonic, wall-clock)
- Note: This property was documented in 6.3.3 release notes but accidentally omitted from that build

v6.3.3 - Categorized Diagnostic Logging:
- All log messages now include category prefixes for easy filtering:
 - [ClockSync] - Clock synchronization, drift, static delay
 - [Timing] - Timing source selection and transitions
 - [Playback] - Pipeline start/stop, startup latency
 - [SyncError] - Periodic sync status monitoring
 - [Correction] - Frame drop/insert correction events
 - [Buffer] - Buffer overrun/underrun events
- Added timing source transition logging: logs when audio clock becomes available/unavailable
- Enhanced startup latency visibility: logs at Info level when non-zero
- Added AudioBufferStats.TimingSourceName property for UI "Stats for Nerds" display
- Example categorized output:
 "[ClockSync] Converged after 5 measurements"
 "[Timing] Source changed: monotonic → audio-clock"
 "[Playback] Starting playback: buffer=500ms, sync offset=+12.3ms"
 "[Correction] Started: DROPPING (syncError=+45ms, timing=audio-clock)"

v6.3.2 - Timing Source Visibility:
- Added ITimedAudioBuffer.TimingSourceName property to track active timing source
- Sync correction logs now include timing source (audio-clock, monotonic, wall-clock)
- AudioPipeline sets timing source name on buffer for consistent logging across components
- Improved logging clarity: MonotonicTimer stats only shown when it's the active source
- Skip MonotonicTimer reset on Clear() when audio clock is active (unnecessary operation)
- Example log output:
 "Sync correction started: DROPPING (..., timing=audio-clock)"
 "Sync correction ended: DROPPING complete (..., timing=monotonic)"

v6.3.1 - Sync Correction Diagnostic Logging:
- Added logging for sync correction mode transitions (None↔Dropping, None↔Inserting)
- Logs include: session counts, cumulative totals, duration, sync error values at transition
- Helps diagnose what triggers large frame drop/insert corrections in VM environments
- Example log output:
 "Sync correction started: DROPPING (syncError=+45.32ms, smoothed=+42.18ms, dropEveryN=480, elapsed=5000ms)"
 "Sync correction ended: DROPPING complete (dropped=11934 session, 11934 total, duration=850ms)"

v6.3.0 - Hybrid Audio Clock Support:
New Features:
- Added optional audio hardware clock support for VM-immune sync timing
 - IAudioPlayer.GetAudioClockMicroseconds(): Optional method returning hardware clock time
 - Default implementation returns null (backward compatible - no code changes needed)
 - AudioPipeline automatically prefers audio clock over MonotonicTimer when available
- Platform implementers can now override with their audio backend's hardware clock:
 - PulseAudio: pa_stream_get_time()
 - ALSA: snd_pcm_htimestamp()
 - PortAudio: outputBufferDacTime
 - CoreAudio: AudioTimeStamp.mHostTime
- Audio hardware clocks are immune to VM wall clock issues (hypervisor scheduling, time sync)
- Includes MonotonicTimer diagnostics from v6.2.0-preview.2

Backward Compatibility:
- Existing apps require ZERO code changes - MonotonicTimer fallback is automatic
- Only apps needing VM immunity need to implement GetAudioClockMicroseconds()

v6.2.0-preview.2 - MonotonicTimer Diagnostics:
- Added telemetry counters to MonotonicTimer for diagnosing timer behavior in VMs
 - TotalCalls, BackwardJumpCount, ForwardJumpCount
 - TotalBackwardJumpMicroseconds, TotalForwardJumpClampedMicroseconds
 - MaxBackwardJumpMicroseconds, MaxForwardJumpMicroseconds
 - GetStatsSummary() for formatted stats display
- MonotonicTimer now automatically reset on AudioPipeline.Clear() to avoid stale state
- Enhanced sync drift logging includes timer stats when error exceeds 50ms
- Reset() now accepts optional resetTelemetry parameter

v6.2.0-preview.1 - VM-Resilient Timing:
- Added MonotonicTimer wrapper for VM environments where wall clock can jump erratically
- Enforces monotonicity (never returns decreasing values) and clamps forward jumps to 50ms max
- Enabled by default in AudioPipeline - all SDK consumers automatically benefit
- No code changes required for existing consumers

v6.1.1 - Logging Improvements:
- Changed clock sync telemetry from Information to Debug log level
- Reduces log noise in production environments (offset/drift measurements are internal details)

v6.1.0 - Track End State Fix:

Bug Fixes:
- Fixed track end not clearing progress display (issue: UI showed stuck at final position with play button)
 - Server sends progress=null to signal track ended, but SDK was keeping the old progress value
 - Root cause: null-coalescing merge (meta.Progress ?? existing.Progress) couldn't distinguish
   "field absent" (partial update - keep existing) from "field is null" (track ended - clear progress)

New Features:
- Optional<T> type: Distinguishes JSON field absent from explicit null
 - Optional.IsPresent: Field was in the JSON (value may be null)
 - Optional.IsAbsent: Field was not in the JSON
 - Used for ServerMetadata.Progress to properly handle track end signal
- OptionalJsonConverterFactory: System.Text.Json converter for Optional<T>

BREAKING (compile-time only, minor):
- ServerMetadata.Progress type changed from PlaybackProgress? to Optional<PlaybackProgress?>
 - Most consumers use GroupState.Metadata.Progress (unchanged, still PlaybackProgress?)
 - Only affects code that directly accesses ServerMetadata from ServerStateMessage

Migration:
- If you directly access ServerMetadata.Progress:
 OLD: if (meta.Progress != null) { ... meta.Progress.TrackProgress ... }
 NEW: if (meta.Progress.IsPresent && meta.Progress.Value != null) { ... meta.Progress.Value.TrackProgress ... }
- GroupState.Metadata.Progress usage is unchanged (SDK handles the Optional internally)

v6.0.0 - Spec Compliance Overhaul:

This release brings the SDK into alignment with the official Sendspin protocol specification
(https://www.sendspin-audio.com/spec/). Non-spec extensions have been removed, missing fields
added, and naming mismatches corrected.

BREAKING CHANGES:

GroupUpdatePayload (group/update message):
- REMOVED: Volume, Muted, Metadata, Position, Shuffle, Repeat
- Per spec, group/update only contains: group_id, group_name, playback_state
- Volume/mute comes via server/state controller object
- Metadata comes via server/state metadata object

ServerHelloPayload (server/hello message):
- REMOVED: GroupId (not in spec)
- REMOVED: ProtocolVersion (string) - replaced by Version (int)
- REMOVED: Support dictionary (not in spec)
- ADDED: Version property (int, must be 1)

StreamStartPayload (stream/start message):
- REMOVED: StreamId (not in spec)
- REMOVED: TargetTimestamp (not in spec)

TrackMetadata (server/state metadata):
- REMOVED: ArtworkUri (not in spec - only ArtworkUrl exists)
- REMOVED: Uri (not in spec)
- REMOVED: MediaType (not in spec)
- CHANGED: Duration and Position are now READ-ONLY computed properties
 - Duration = Progress?.TrackDuration / 1000.0 (seconds from ms)
 - Position = Progress?.TrackProgress / 1000.0 (seconds from ms)
- ADDED: Timestamp (server timestamp in microseconds)
- ADDED: AlbumArtist (may differ from Artist on compilations)
- ADDED: Year (release year)
- ADDED: Track (track number on album)
- ADDED: Progress (PlaybackProgress object with TrackProgress, TrackDuration, PlaybackSpeed)
- ADDED: Repeat (string: "off", "one", "all")
- ADDED: Shuffle (bool)

ClientHelloPayload (client/hello message):
- FIXED: Property name changed from "player_support" to "player@v1_support" per spec

RETAINED EXTENSIONS (documented):
These SDK extensions are kept intentionally and documented as non-spec:
- client/sync_offset, client/sync_offset_ack: GroupSync acoustic calibration support
- PlayerStatePayload.BufferLevel: Diagnostic buffer level reporting
- PlayerStatePayload.Error: Error message reporting

DOCUMENTATION:
- GroupState: Updated XML docs clarifying field sources:
 - GroupId, Name, PlaybackState from group/update
 - Volume, Muted from server/state controller
 - Metadata, Shuffle, Repeat from server/state metadata

Migration Guide:
- If you accessed GroupUpdatePayload.Volume/Muted/Metadata, use GroupState which aggregates
 data from both group/update and server/state messages
- If you set TrackMetadata.Duration/Position directly, set Progress object instead
- If you used TrackMetadata.Uri/ArtworkUri, these were non-spec and have been removed
- If you accessed ServerHelloPayload.ProtocolVersion, use Version (int) instead

v5.4.1 - Group Handling Fixes:
Bug Fixes:
- Fixed GroupId not updating when player switches groups
 - HandleGroupUpdate() used null-coalescing assignment (??=) which only set GroupId when creating
   a new GroupState object, never updating it on subsequent group/update messages
 - Now always updates GroupId when non-empty, allowing proper group switching behavior
- Added group_name field to GroupUpdatePayload (per Sendspin spec)
 - GroupState.Name is now populated from server's group_name field
 - Enables proper display of group names in UI (e.g., Switch Group button tooltip)

Impact:
- Fixes group switching functionality for players moving between groups
- GroupState.Name now contains the friendly group name when provided by server

v5.4.0 - Player Volume Separation & Server Command ACK:
BREAKING CHANGES:
- GroupState.Volume and GroupState.Muted now represent the GROUP AVERAGE (display only)
 - Previously used for both group display and player audio control
 - Player volume/mute is now tracked separately via PlayerState

New Features:
- PlayerState model: Represents this player's own volume (0-100) and mute state
- PlayerStateChanged event: Fires when server changes player volume/mute via server/command
- CurrentPlayerState property: Access current player volume/mute state
- Automatic ACK: SDK sends client/state acknowledgement after applying server/command
 - Fixes spec compliance where server didn't know player applied volume changes
 - Enables correct group volume calculations for multi-player scenarios
- ClientCapabilities.InitialVolume/InitialMuted: Set player's initial state on connection

Migration:
- Subscribe to PlayerStateChanged (not GroupStateChanged) for volume/mute from server
- GroupStateChanged still used for playback state, metadata, track info
- See MIGRATION-5.4.0.md for detailed migration guide and code examples

Impact:
- Fixes group volume issues where players at different levels (15%, 45%) incorrectly
 applied group average (30%) to their own audio
- Now correctly ignores group average and only responds to server/command targeting
 this specific player

v5.3.0 - Artwork Support Fix & Discovery Improvements:
Bug Fixes:
- Fixed artwork@v1_support format to match Sendspin spec
 - Property name: artwork_support → artwork@v1_support
 - Structure: ArtworkSupport.Channels is now List<ArtworkChannelSpec>
 - Previous format caused handshake failures with spec-compliant servers

New Features:
- Added ArtworkChannelSpec class for artwork channel configuration
 - Source: "album" | "artist" | "none"
 - Format: "jpeg" | "png" | "bmp"
 - MediaWidth/MediaHeight: max dimensions in pixels
- Added MdnsServerDiscovery.IsDiscovering property

BREAKING (compile-time only):
- ArtworkSupport.Channels type changed from int to List<ArtworkChannelSpec>
- ArtworkSupport.SupportedFormats and MaxSize removed (use ArtworkChannelSpec)

v5.2.2 - Enhanced 3-Point Interpolation:
Improvements:
- Upgraded sync correction from 2-point to 3-point weighted interpolation
 - DROP: 0.25*lastOutput + 0.5*frameA + 0.25*frameB (was: (A+B)*0.5)
 - INSERT: 0.25*lastOutput + 0.5*nextFrame + 0.25*futureFrame (was: (last+next)*0.5)
- Added PeekSamplesFromBufferAtOffset() for looking ahead multiple frames
- INSERT now peeks 2 frames ahead for better curve fitting

Impact:
- Smoother transitions during sync corrections, especially for low-frequency content
- Better phase continuity through edit points
- Falls back to 2-point if insufficient frames available

v5.2.1 - Sync Correction Audio Quality:
Bug Fixes:
- Fixed audible clicks/pops during frame drop/insert sync corrections
 - Drop operations now blend adjacent frames: (frameA + frameB) * 0.5
 - Insert operations now interpolate: (lastOutput + nextInput) * 0.5
 - Previously used raw frame repetition causing waveform discontinuities
- Added PeekSamplesFromBuffer() helper for non-consuming buffer reads

Impact:
- Sync corrections (when error exceeds 15ms) are now inaudible
- Smoother audio during clock drift recovery
- No API changes - drop-in replacement for 5.2.0

v5.2.0 - Client Port Change (Spec Alignment):
BREAKING CHANGE:
- Default client listening port changed from 8927 to 8928
 - Aligns with Sendspin spec update (Sendspin/spec#62)
 - Servers: 8927 (_sendspin-server._tcp)
 - Clients: 8928 (_sendspin._tcp)
 - This allows servers and clients to coexist on the same machine without port conflicts

Affected Classes:
- ListenerOptions.Port: Default changed from 8927 to 8928
- AdvertiserOptions.Port: Default changed from 8927 to 8928

Migration:
- If your server expects clients on port 8927, update to discover/connect on 8928
- Or explicitly configure ListenerOptions/AdvertiserOptions to use 8927 for backward compatibility

v5.1.1 - Volume Handling Fix:
Bug Fixes:
- Fixed incorrect application of group volume to audio pipeline
 - HandleGroupUpdate and HandleServerState were applying GROUP volume (average of all players)
   to the local audio output, overriding player-specific volume settings
 - Per Sendspin spec: only server/command contains PLAYER volume to apply locally
 - group/update and server/state contain GROUP volume for UI display only
- This prevents volume sync issues in multi-player groups where each player has different
 volume levels (e.g., 60%, 80%, 100%) - previously all players would jump to group average

Impact:
- Multi-player groups now correctly maintain individual player volumes
- server/command remains the only message that applies volume to audio pipeline
- UI state (_currentGroup.Volume) still updated for display purposes

v5.1.0 - Faster Playback Startup:
Performance:
- Reduced audio startup delay from ~1000ms to ~200-300ms
 - Sync burst no longer blocks pipeline initialization
 - Smart burst skip: skips re-sync if clock already converged
 - Zero chunk loss during decoder/buffer initialization

New Features:
- IAudioPipeline.IsReady: Check if pipeline can accept audio chunks
- Pre-buffer queue: Captures early chunks during initialization, drains after startup

Implementation Details:
- SendTimeSyncBurstAsync now fire-and-forget (doesn't block StartAsync)
- Early chunks queued in ConcurrentQueue (max 100 chunks, ~2 seconds)
- Queue drained after pipeline.StartAsync() completes
- Queue cleared on stream/start and stream/end

Scenarios improved:
- First play after connection: No longer waits 450ms for sync burst
- Pause/resume: Near-instant resume when clock already synced
- Track changes: Faster transitions between tracks

For older release notes (v5.0.0 and earlier), see https://github.com/chrisuthe/windowsSpin/releases