Sendspin.SDK 6.3.5

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

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

Migration Guide

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
6.3.5 282 2/3/2026
6.3.4 95 2/3/2026
6.3.3 89 2/3/2026
6.3.2 90 2/3/2026
6.3.1 90 2/3/2026
6.3.0 105 2/3/2026
6.2.0-preview.2 48 2/3/2026
6.2.0-preview.1 59 2/3/2026
6.1.1 158 1/29/2026
6.1.0 84 1/29/2026
6.0.1 116 1/29/2026
6.0.0 115 1/26/2026
5.4.1 93 1/26/2026
5.4.0 92 1/26/2026
5.3.0 100 1/25/2026
5.2.2 90 1/22/2026
5.2.1 172 1/22/2026
5.2.0 310 1/16/2026
5.1.1 89 1/16/2026
5.1.0 258 1/11/2026
Loading failed

v6.3.5 - Suppress MonotonicTimer Logs When Not Active:
- MonotonicTimer now only logs "Timer jumped" messages when it's the active timing source
- When audio-clock is active, MonotonicTimer jump messages are suppressed completely
- Added IsActiveTimingSource property to MonotonicTimer for timing source awareness
- AudioPipeline automatically sets this property based on timing source selection
- Significantly reduces log noise in VM environments where audio-clock is working correctly

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

v5.0.3:
Bug Fixes:
- Fixed SDK always reporting 100% volume to server on connect
 - Initial client/state message was hardcoded to send volume=100, muted=false
 - Now uses InitialVolume and InitialMuted from ClientCapabilities

New Features:
- ClientCapabilities.InitialVolume: Set initial volume (0-100) reported to server (default: 100)
- ClientCapabilities.InitialMuted: Set initial mute state reported to server (default: false)

v5.0.2:
Documentation:
- Completely rewritten README for v5.0.0 external sync correction architecture
- Added comprehensive usage examples for SyncCorrectionCalculator
- Updated architecture diagram showing app-layer correction
- Added v5.0.0 migration guide with before/after code examples
- Documented tiered correction strategy and configuration options

v5.0.1:
Bug Fixes:
- Fixed Task.Run exception handling in TimedAudioBuffer re-anchor event firing
 - If Task.Run throws (ThreadPool exhaustion, OOM), the pending flag is now reset
 - Prevents permanently blocking future re-anchor events in rare edge cases

Improvements:
- SyncCorrectionCalculator constructor now validates sampleRate and channels > 0
 - Throws ArgumentOutOfRangeException with clear message for invalid inputs
- NotifyExternalCorrection now throws separate exceptions for each invalid parameter
- Added Debug.Assert to catch logically invalid state (both drop and insert > 0)
- Enhanced documentation for thread safety, contracts, and IDisposable lifecycle

v5.0.0 - Major Architecture Change: External Sync Correction

BREAKING CHANGES:
This release moves sync correction logic OUT of the SDK into the application layer.
The SDK now only reports sync error - your app decides HOW to correct.

This enables:
- Different platforms to use different correction strategies (browser: playbackRate, Windows: drop/insert, Linux: ALSA rate)
- Apps to offer multiple correction profiles
- Cleaner SDK with single responsibility (buffering + timing, not correction)

New Public API:
- ISyncCorrectionProvider: Interface for correction strategy abstraction
 - CurrentMode: SyncCorrectionMode (None, Resampling, Dropping, Inserting)
 - DropEveryNFrames: Interval for dropping frames (0 = disabled)
 - InsertEveryNFrames: Interval for inserting frames (0 = disabled)
 - TargetPlaybackRate: Rate for resampling-based correction (1.0 = normal)
 - CorrectionChanged event: Notifies when correction parameters change
 - UpdateFromSyncError(): Call with buffer's sync error values
 - Reset(): Reset to initial state on buffer clear/restart

- SyncCorrectionCalculator: Default ISyncCorrectionProvider implementation
 - Implements tiered correction matching the CLI: deadband → resampling → drop/insert
 - Uses SyncCorrectionOptions for configuration
 - Thread-safe for use from audio callbacks

- ITimedAudioBuffer new members:
 - SyncErrorMicroseconds: Raw sync error (positive = behind, negative = ahead)
 - SmoothedSyncErrorMicroseconds: EMA-filtered error for stable decisions
 - ReadRaw(): Read samples WITHOUT internal correction (use with external provider)
 - NotifyExternalCorrection(): Report drops/inserts for accurate error tracking

Deprecated API (still functional for backwards compatibility):
- ITimedAudioBuffer.TargetPlaybackRate [Obsolete]
- ITimedAudioBuffer.TargetPlaybackRateChanged [Obsolete]
- ITimedAudioBuffer.Read() [Obsolete] - use ReadRaw() with external correction

Migration Guide:
OLD (SDK applies correction internally):
 var read = buffer.Read(samples, currentTime);
 buffer.TargetPlaybackRateChanged += rate => resampler.Rate = rate;

NEW (App applies correction externally):
 // Create correction provider
 var correctionProvider = new SyncCorrectionCalculator(
     buffer.SyncOptions, sampleRate, channels);

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

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

 // Apply correction based on provider's decisions
 var mode = correctionProvider.CurrentMode;
 if (mode == SyncCorrectionMode.Dropping) {
     // Apply drop every N frames
     buffer.NotifyExternalCorrection(droppedCount, 0);
 }

 // Or subscribe to rate changes for resampling
 correctionProvider.CorrectionChanged += p => resampler.Rate = p.TargetPlaybackRate;

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

Benefits:
- Browser apps can use native playbackRate (WSOLA time-stretching)
- Windows apps can choose WDL resampler, SoundTouch, or drop/insert only
- Linux apps can use ALSA hardware rate adjustment
- Embedded systems can implement platform-specific correction
- Testability: Correction logic isolated, easier to unit test

v3.5.1:
Improved Push-Model Backend Support:
- Refactored calibrated startup latency handling for cleaner architecture
- Instead of adding latency to every sync error calculation, now backdates
 _playbackStartLocalTime when playback starts
- This handles static buffer pre-fill time once at initialization
- Sync error formula remains clean: elapsed - samplesReadTime
- Drift/fluctuation correction logic unchanged

v3.5.0:
New Features:
- CalibratedStartupLatencyMicroseconds: New property on ITimedAudioBuffer for push-model audio backend support
 - Compensates for ALSA-style backends that pre-fill output buffers before playback starts
 - Without compensation, buffer prefill causes constant negative sync error (~-200ms)
 - Pull-model backends (WASAPI) leave this at 0 (default) - no behavior change
- IAudioPlayer.CalibratedStartupLatencyMs: New property with default implementation returning 0
 - ALSA players can override to report their measured startup latency
 - Value is automatically passed to TimedAudioBuffer by AudioPipeline

Sync Error Formula Update:
- syncError = elapsed - samplesReadTime + CalibratedStartupLatencyMicroseconds
- When CalibratedStartupLatencyMicroseconds = 0, behavior is unchanged from previous versions

v3.4.0:
Improved Sync Correction:
- Replaced fixed rate steps (1-2%) with proportional correction
 - Rate = 1.0 + (error / CorrectionTargetSeconds / 1,000,000)
 - Prevents overshoot by adjusting rate based on error magnitude
 - Example: 10ms error with 3s target = 0.33% rate adjustment (vs fixed 1-2%)
- Added EMA smoothing for sync error measurements (alpha=0.1)
 - Filters measurement jitter before making correction decisions
 - Prevents rapid oscillation between correction modes
- Tiered correction now: deadband (1ms) -> proportional (1-15ms) -> drop/insert (15ms+)

Deprecations:
- EntryDeadbandMicroseconds: No longer used, marked [Obsolete]
- ExitDeadbandMicroseconds: No longer used, marked [Obsolete]
- BypassDeadband: No longer used, marked [Obsolete]
These properties are retained for backward compatibility but have no effect.

v3.3.1:
New Features:
- IAudioPlayer.OutputFormat: Optional property exposing the audio format sent to output device
 - Default implementation returns null (non-breaking)
 - Override in your implementation to expose actual output format
- IAudioPipeline.OutputFormat: Optional property for output format (complements CurrentFormat)
 - Default implementation returns null (non-breaking)
 - Useful for debugging format conversions in the pipeline

v3.3.0:
New Features:
- SyncCorrectionOptions: All sync correction parameters are now configurable via TimedAudioBuffer constructor
 - EntryDeadbandMicroseconds: Error threshold to start correcting (default: 2ms)
 - ExitDeadbandMicroseconds: Error threshold to stop correcting with hysteresis (default: 0.5ms)
 - MaxSpeedCorrection: Maximum playback rate adjustment (default: 2%, CLI uses 4%)
 - CorrectionTargetSeconds: Target time to eliminate drift (default: 3s, CLI uses 2s)
 - ResamplingThresholdMicroseconds: Boundary between resampling and drop/insert (default: 15ms)
 - ReanchorThresholdMicroseconds: Error threshold for buffer clear/restart (default: 500ms)
 - StartupGracePeriodMicroseconds: No correction during startup (default: 500ms)
 - ScheduledStartGraceWindowMicroseconds: Start timing tolerance (default: 10ms)
 - BypassDeadband: Disable deadband for continuous correction (default: false)
- Static presets: SyncCorrectionOptions.Default and SyncCorrectionOptions.CliDefaults
- ITimedAudioBuffer.SyncOptions property for runtime inspection
- Validation via SyncCorrectionOptions.Validate() to catch invalid configurations

This enables SDK consumers to tune sync behavior for different platforms (ALSA, PulseAudio, CoreAudio, embedded)
without code changes, addressing platform-specific timing characteristics.

v3.2.0:
Sync Correction Tuning:
- Reduced MaxSpeedCorrection from 4% to 2% to prevent oscillation on platforms with higher timing variability (e.g., PulseAudio)
- Increased CorrectionTargetSeconds from 2.0s to 3.0s for gentler, more stable convergence
- These changes improve stability on cross-platform audio backends while maintaining good sync accuracy

v3.1.0:
New Features:
- ClockSyncConverged event: Notifies when clock synchronizer reaches stable estimate
 - Indicates client is ready for sample-accurate synchronized playback
 - Useful for UI feedback and playback state management
- SyncOffsetApplied event: Reports when external calibration adjusts sync offset
 - Triggered by GroupSync acoustic calibration
 - Provides offset value and source information via SyncOffsetEventArgs

Internal Improvements:
- Extracted WaitForHandshakeAsync for cleaner handshake timeout handling
- Improved code organization in SendSpinHostService

v3.0.2:
Documentation:
- Completely rewritten README with updated architecture diagram
- Added sync correction tier table explaining hysteresis behavior
- Updated Quick Start example with device info configuration
- Added migration guides for v2.0 and v3.0 upgrades

v3.0.1:
New Features:
- Configurable device info: ClientCapabilities now exposes ProductName, Manufacturer, and SoftwareVersion
 - All fields are optional and omitted from JSON when null
 - SDK consumers can now identify their player to servers

Bug Fixes:
- Fixed sync correction oscillation with hysteresis deadband
 - Entry threshold: 2ms (start correcting when error exceeds this)
 - Exit threshold: 0.5ms (stop correcting when error drops below this)
 - Prevents constant toggling between "Resampling" and "None" modes
 - Results in smoother, more stable playback rate adjustments

v3.0.0 - Breaking Changes:
- BREAKING: IClockSynchronizer interface now requires HasMinimalSync property
 - Returns true after 2+ time sync measurements (vs IsConverged which requires 5+)
 - Existing implementations must add this property

New Features:
- Quick-start playback: reduced time-to-playback from ~5 seconds to ~300-500ms
 - AudioPipeline now uses HasMinimalSync instead of IsConverged for playback gating
 - Matches JS/CLI player behavior for faster startup
 - Sync correction system handles any initial estimation errors
- Default convergence timeout reduced from 5000ms to 1000ms

Migration Guide:
- If you implement IClockSynchronizer, add: bool HasMinimalSync => MeasurementCount >= 2;
- If you only use KalmanClockSynchronizer (most users), no changes needed

v2.2.1:
Bug Fixes:
- CRITICAL: Fixed sync error calculation that prevented resampling correction from converging
 - Removed incorrect output latency subtraction from sync error formula
 - Output latency is a constant offset that doesn't affect sync rate correction
 - Previously caused permanent negative sync error (~-115ms), continuous slowdown, and buffer growth
- ITimedAudioBuffer.OutputLatencyMicroseconds is now informational only (not used in sync calculation)

v2.2.0:
New Features:
- Tiered sync correction strategy matching JS client for smoother playback:
 - Tier 1 (<2ms): Deadband, no correction needed
 - Tier 2 (2-15ms): Smooth playback rate adjustment via resampling (imperceptible)
 - Tier 3 (>15ms): Frame drop/insert for faster correction
- ITimedAudioBuffer.TargetPlaybackRate property for resampler-based sync correction
- ITimedAudioBuffer.TargetPlaybackRateChanged event for rate change notifications
- SyncCorrectionMode.Resampling enum value for stats reporting
- AudioPipeline clock sync gating: waits for Kalman filter convergence before playback
- Reduced default buffer target from 500ms to 250ms for faster playback start
- Configurable convergence timeout (default 5s) prevents indefinite waiting

v2.1.1:
Bug Fixes:
- Fixed StaticDelayMs not working: TimedAudioBuffer now respects scheduled start times
 from IClockSynchronizer, enabling the static delay feature to properly delay/advance
 playback relative to other clients

v2.1.0:
New Features:
- Adaptive forgetting factor in KalmanClockSynchronizer for improved clock sync stability
- New client/sync_offset protocol message support for GroupSync coordination
- SyncOffsetReceived event on SendSpinClient for handling sync offset messages

Bug Fixes:
- Fixed position/duration values: now correctly reported in seconds (was milliseconds)
- Fixed ClientGoodbyeMessage to use proper envelope format with payload

v2.0.0 - Breaking Changes:
- BREAKING: IClockSynchronizer.HardwareLatencyMs property removed (latency now handled in audio buffer layer)
- BREAKING: IAudioPipeline now requires SwitchDeviceAsync() method implementation
- BREAKING: IAudioPlayer now requires SwitchDeviceAsync() method implementation
- Now multi-targets net8.0 and net10.0

New Features:
- Audio device hot-switching without stopping playback
- SendspinHostService: Control mDNS advertising independently (StopAdvertisingAsync/StartAdvertisingAsync)
- TimedAudioBuffer.ResetSyncTracking() for soft re-anchor after device switches
- Improved server name display from mDNS discovery (smarter hostname cleanup)
- Sync timing improvements for better multi-room accuracy