Sendspin.SDK
6.3.5
dotnet add package Sendspin.SDK --version 6.3.5
NuGet\Install-Package Sendspin.SDK -Version 6.3.5
<PackageReference Include="Sendspin.SDK" Version="6.3.5" />
<PackageVersion Include="Sendspin.SDK" Version="6.3.5" />
<PackageReference Include="Sendspin.SDK" />
paket add Sendspin.SDK --version 6.3.5
#r "nuget: Sendspin.SDK, 6.3.5"
#:package Sendspin.SDK@6.3.5
#addin nuget:?package=Sendspin.SDK&version=6.3.5
#tool nuget:?package=Sendspin.SDK&version=6.3.5
Sendspin SDK
A cross-platform .NET SDK for the Sendspin synchronized multi-room audio protocol. Build players that sync perfectly with Music Assistant and other Sendspin-compatible players.
Features
- Multi-room Audio Sync: Microsecond-precision clock synchronization using Kalman filtering
- External Sync Correction (v5.0+): SDK reports sync error, your app applies correction
- Platform Flexibility: Use playback rate, drop/insert, or hardware rate adjustment
- Fast Startup: Audio plays within ~300ms of connection
- Protocol Support: Full Sendspin WebSocket protocol implementation
- Server Discovery: mDNS-based automatic server discovery
- Audio Decoding: Built-in PCM, FLAC, and Opus codec support
- Cross-Platform: Works on Windows, Linux, and macOS (.NET 8.0 / .NET 10.0)
- Audio Device Switching: Hot-switch audio output devices without interrupting playback
Installation
dotnet add package Sendspin.SDK
Quick Start
using Sendspin.SDK.Client;
using Sendspin.SDK.Connection;
using Sendspin.SDK.Synchronization;
// Create dependencies
var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole());
var connection = new SendspinConnection(loggerFactory.CreateLogger<SendspinConnection>());
var clockSync = new KalmanClockSynchronizer(loggerFactory.CreateLogger<KalmanClockSynchronizer>());
// Create client with device info
var capabilities = new ClientCapabilities
{
ClientName = "My Player",
ProductName = "My Awesome Player",
Manufacturer = "My Company",
SoftwareVersion = "1.0.0"
};
var client = new SendspinClientService(
loggerFactory.CreateLogger<SendspinClientService>(),
connection,
clockSync,
capabilities
);
// Connect to server
await client.ConnectAsync(new Uri("ws://192.168.1.100:8927/sendspin"));
// Handle events
client.GroupStateChanged += (sender, group) =>
{
Console.WriteLine($"Now playing: {group.Metadata?.Title}");
};
// Send commands
await client.SendCommandAsync("play");
await client.SetVolumeAsync(75);
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ Your Application │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ SyncCorrectionCalculator │ Your Resampler/Drop Logic │ │
│ │ (correction decisions) │ (applies correction) │ │
│ └─────────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ SendspinClientService │ AudioPipeline │ IAudioPlayer │
│ (protocol handling) │ (orchestration) │ (your impl) │
├─────────────────────────────────────────────────────────────────┤
│ SendspinConnection │ KalmanClockSync │ TimedAudioBuffer │
│ (WebSocket) │ (timing) │ (reports error) │
├─────────────────────────────────────────────────────────────────┤
│ OpusDecoder │ FlacDecoder │ PcmDecoder │
└─────────────────────────────────────────────────────────────────┘
Namespaces:
Sendspin.SDK.Client- Client services and capabilitiesSendspin.SDK.Connection- WebSocket connection managementSendspin.SDK.Protocol- Message types and serializationSendspin.SDK.Synchronization- Clock sync (Kalman filter)Sendspin.SDK.Audio- Pipeline, buffer, decoders, and sync correctionSendspin.SDK.Discovery- mDNS server discoverySendspin.SDK.Models- Data models (GroupState, TrackMetadata)
Sync Correction System (v5.0+)
Starting with v5.0.0, sync correction is external - the SDK reports sync error and your application decides how to correct it. This enables platform-specific correction strategies:
- Windows: WDL resampler, SoundTouch, or drop/insert
- Browser: Native
playbackRate(WSOLA time-stretching) - Linux: ALSA hardware rate adjustment, PipeWire rate
- Embedded: Platform-specific DSP
How It Works
SDK (reports error only) App (applies correction)
────────────────────────────────────────────────────────────────
TimedAudioBuffer SyncCorrectionCalculator
├─ ReadRaw() - no correction ├─ UpdateFromSyncError()
├─ SyncErrorMicroseconds ├─ DropEveryNFrames
├─ SmoothedSyncErrorMicroseconds ├─ InsertEveryNFrames
└─ NotifyExternalCorrection() └─ TargetPlaybackRate
Tiered Correction Strategy
The SyncCorrectionCalculator implements the same tiered strategy as the reference CLI:
| Sync Error | Correction Method | Description |
|---|---|---|
| < 1ms | None (deadband) | Error too small to matter |
| 1-15ms | Playback rate adjustment | Smooth resampling (imperceptible) |
| 15-500ms | Frame drop/insert | Faster correction for larger drift |
| > 500ms | Re-anchor | Clear buffer and restart sync |
Usage Example
using Sendspin.SDK.Audio;
// Create the correction calculator
var correctionProvider = new SyncCorrectionCalculator(
SyncCorrectionOptions.Default, // or SyncCorrectionOptions.CliDefaults
sampleRate: 48000,
channels: 2
);
// Subscribe to correction changes
correctionProvider.CorrectionChanged += provider =>
{
// Update your resampler rate
myResampler.Rate = provider.TargetPlaybackRate;
// Or handle drop/insert
if (provider.CurrentMode == SyncCorrectionMode.Dropping)
{
dropEveryN = provider.DropEveryNFrames;
}
};
// In your audio callback:
public int Read(float[] buffer, int offset, int count)
{
// Read raw samples (no internal correction)
int read = timedAudioBuffer.ReadRaw(buffer, offset, count, currentTimeMicroseconds);
// Update correction provider with current error
correctionProvider.UpdateFromSyncError(
timedAudioBuffer.SyncErrorMicroseconds,
timedAudioBuffer.SmoothedSyncErrorMicroseconds
);
// Apply your correction strategy...
// If dropping/inserting, notify the buffer:
timedAudioBuffer.NotifyExternalCorrection(samplesDropped, samplesInserted);
return outputCount;
}
Configuring Sync Behavior
// 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
HardwareLatencyMsremoved - No action needed, latency handled automaticallyIAudioPipeline.SwitchDeviceAsync()required - Implement for device switchingIAudioPlayer.SwitchDeviceAsync()required - Implement in your audio player
Example Projects
See the Windows client for a complete WPF implementation using NAudio/WASAPI with external sync correction.
License
MIT License - see LICENSE for details.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Concentus (>= 2.2.2)
- Fleck (>= 1.2.0)
- Makaretu.Dns.Multicast (>= 0.27.0)
- Microsoft.Extensions.Logging.Abstractions (>= 8.0.2)
- Zeroconf (>= 3.7.16)
-
net8.0
- Concentus (>= 2.2.2)
- Fleck (>= 1.2.0)
- Makaretu.Dns.Multicast (>= 0.27.0)
- Microsoft.Extensions.Logging.Abstractions (>= 8.0.2)
- Zeroconf (>= 3.7.16)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 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 |
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