Sendspin.SDK
9.3.0
dotnet add package Sendspin.SDK --version 9.3.0
NuGet\Install-Package Sendspin.SDK -Version 9.3.0
<PackageReference Include="Sendspin.SDK" Version="9.3.0" />
<PackageVersion Include="Sendspin.SDK" Version="9.3.0" />
<PackageReference Include="Sendspin.SDK" />
paket add Sendspin.SDK --version 9.3.0
#r "nuget: Sendspin.SDK, 9.3.0"
#:package Sendspin.SDK@9.3.0
#addin nuget:?package=Sendspin.SDK&version=9.3.0
#tool nuget:?package=Sendspin.SDK&version=9.3.0
Sendspin SDK
A cross-platform .NET SDK for the Sendspin synchronized multi-room audio protocol. Build players that sync perfectly with Music Assistant and other Sendspin-compatible players.
Features
- Multi-room Audio Sync: Microsecond-precision clock synchronization using Kalman filtering
- External Sync Correction (v5.0+): SDK reports sync error, your app applies correction
- Platform Flexibility: Use playback rate, drop/insert, or hardware rate adjustment
- Fast Startup: Audio plays within ~300ms of connection
- Protocol Support: Full Sendspin WebSocket protocol implementation
- Server Discovery: mDNS-based automatic server discovery
- Audio Decoding: Built-in PCM, FLAC, and Opus codec support
- Cross-Platform: Works on Windows, Linux, and macOS (.NET 8.0 / .NET 10.0)
- NativeAOT & Trimming: Fully compatible with
PublishAotand IL trimming for single-file native executables with no .NET runtime dependency - Audio Device Switching: Hot-switch audio output devices without interrupting playback
Installation
dotnet add package Sendspin.SDK
Quick Start
using Sendspin.SDK.Client;
using Sendspin.SDK.Connection;
using Sendspin.SDK.Synchronization;
// Create dependencies
var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole());
var connection = new SendspinConnection(loggerFactory.CreateLogger<SendspinConnection>());
var clockSync = new KalmanClockSynchronizer(loggerFactory.CreateLogger<KalmanClockSynchronizer>());
// Create client with device info
var capabilities = new ClientCapabilities
{
ClientName = "My Player",
ProductName = "My Awesome Player",
Manufacturer = "My Company",
SoftwareVersion = "1.0.0"
};
var client = new SendspinClientService(
loggerFactory.CreateLogger<SendspinClientService>(),
connection,
clockSync,
capabilities
);
// Connect to server
await client.ConnectAsync(new Uri("ws://192.168.1.100:8927/sendspin"));
// Handle events
client.GroupStateChanged += (sender, group) =>
{
Console.WriteLine($"Now playing: {group.Metadata?.Title}");
};
// Send commands
await client.SendCommandAsync("play");
await client.SetVolumeAsync(75);
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ Your Application │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ SyncCorrectionCalculator │ Your Resampler/Drop Logic │ │
│ │ (correction decisions) │ (applies correction) │ │
│ └─────────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ SendspinClientService │ AudioPipeline │ IAudioPlayer │
│ (protocol handling) │ (orchestration) │ (your impl) │
├─────────────────────────────────────────────────────────────────┤
│ SendspinConnection │ KalmanClockSync │ TimedAudioBuffer │
│ (WebSocket) │ (timing) │ (reports error) │
├─────────────────────────────────────────────────────────────────┤
│ OpusDecoder │ FlacDecoder │ PcmDecoder │
└─────────────────────────────────────────────────────────────────┘
Namespaces:
Sendspin.SDK.Client- Client services and capabilitiesSendspin.SDK.Connection- WebSocket connection managementSendspin.SDK.Protocol- Message types and serializationSendspin.SDK.Synchronization- Clock sync (Kalman filter)Sendspin.SDK.Audio- Pipeline, buffer, decoders, and sync correctionSendspin.SDK.Discovery- mDNS server discoverySendspin.SDK.Models- Data models (GroupState, TrackMetadata)
Sync Correction System (v5.0+)
Starting with v5.0.0, sync correction is external - the SDK reports sync error and your application decides how to correct it. This enables platform-specific correction strategies:
- Windows: WDL resampler, SoundTouch, or drop/insert
- Browser: Native
playbackRate(WSOLA time-stretching) - Linux: ALSA hardware rate adjustment, PipeWire rate
- Embedded: Platform-specific DSP
How It Works
SDK (reports error only) App (applies correction)
────────────────────────────────────────────────────────────────
TimedAudioBuffer SyncCorrectionCalculator
├─ ReadRaw() - no correction ├─ UpdateFromSyncError()
├─ SyncErrorMicroseconds ├─ DropEveryNFrames
├─ SmoothedSyncErrorMicroseconds ├─ InsertEveryNFrames
└─ NotifyExternalCorrection() └─ TargetPlaybackRate
Tiered Correction Strategy
The SyncCorrectionCalculator implements the same tiered strategy as the reference CLI:
| Sync Error | Correction Method | Description |
|---|---|---|
| < 1ms | None (deadband) | Error too small to matter |
| 1-15ms | Playback rate adjustment | Smooth resampling (imperceptible) |
| 15-500ms | Frame drop/insert | Faster correction for larger drift |
| > 500ms | Re-anchor | Clear buffer and restart sync |
Usage Example
using Sendspin.SDK.Audio;
// Create the correction calculator
var correctionProvider = new SyncCorrectionCalculator(
SyncCorrectionOptions.Default, // or SyncCorrectionOptions.CliDefaults
sampleRate: 48000,
channels: 2
);
// Subscribe to correction changes
correctionProvider.CorrectionChanged += provider =>
{
// Update your resampler rate
myResampler.Rate = provider.TargetPlaybackRate;
// Or handle drop/insert
if (provider.CurrentMode == SyncCorrectionMode.Dropping)
{
dropEveryN = provider.DropEveryNFrames;
}
};
// In your audio callback:
public int Read(float[] buffer, int offset, int count)
{
// Read raw samples (no internal correction)
int read = timedAudioBuffer.ReadRaw(buffer, offset, count, currentTimeMicroseconds);
// Update correction provider with current error
correctionProvider.UpdateFromSyncError(
timedAudioBuffer.SyncErrorMicroseconds,
timedAudioBuffer.SmoothedSyncErrorMicroseconds
);
// Apply your correction strategy...
// If dropping/inserting, notify the buffer:
timedAudioBuffer.NotifyExternalCorrection(samplesDropped, samplesInserted);
return outputCount;
}
Configuring Sync Behavior
The correction caps are spec conformance points, not tuning knobs. The effective playback speed
must stay within ±0.5% of normal (a protocol MUST), and the steady-state error must stay within
±1 ms, so the dead band sits an order of magnitude below that at 100 µs. Both defaults match the
reference players this SDK shares a group with. Setting MaxSpeedCorrection above 0.5% does not
raise the cap — the value is clamped where correction is applied and a warning is logged once.
Errors too large to close inside the cap are handled by a one-shot resynchronization that the
spec exempts from it, not by exceeding it.
// Use default settings (spec caps: 0.5% max, 100 us dead band, 3s target)
var options = SyncCorrectionOptions.Default;
// Use CLI-compatible settings (faster convergence, same caps)
var options = SyncCorrectionOptions.CliDefaults;
// Custom options
var options = new SyncCorrectionOptions
{
CorrectionTargetSeconds = 2.0, // Time to eliminate drift
ResamplingThresholdMicroseconds = 15_000, // Resampling vs drop/insert
ReanchorThresholdMicroseconds = 500_000, // Clear buffer threshold
StartupGracePeriodMicroseconds = 500_000, // No correction during startup
};
var calculator = new SyncCorrectionCalculator(options, sampleRate, channels);
Advertised buffer capacity
ClientCapabilities.BufferCapacity is derived from the SDK's decoded-buffer duration and the
formats you advertise, rather than defaulting to a flat 32 MB. The spec makes this a hard
per-player byte limit the server may fill toward, so an over-advertisement is audio the server
legally sends and the client discards before playing it. A value set explicitly is honoured only
up to what the buffer can actually hold; anything larger is clamped and reported at
client/hello time.
Platform-Specific Audio
The SDK handles decoding, buffering, and sync error reporting. You implement IAudioPlayer for audio output:
public class MyAudioPlayer : IAudioPlayer
{
public long OutputLatencyMicroseconds { get; private set; }
public Task InitializeAsync(AudioFormat format, CancellationToken ct)
{
// Initialize your audio backend (WASAPI, PulseAudio, CoreAudio, etc.)
}
public int Read(float[] buffer, int offset, int count)
{
// Called by audio thread - read from TimedAudioBuffer.ReadRaw()
// Apply sync correction externally
}
// ... other methods
}
Platform suggestions:
- Windows: NAudio with WASAPI (
WasapiOut) - Linux: OpenAL, PulseAudio, or PipeWire
- macOS: AudioToolbox or AVAudioEngine
- Cross-platform: SDL2
Server Discovery
Automatically discover Sendspin servers on your network:
var discovery = new MdnsServerDiscovery(logger);
discovery.ServerDiscovered += (sender, server) =>
{
Console.WriteLine($"Found: {server.Name} at {server.Uri}");
};
await discovery.StartAsync();
Device Info
Identify your player to servers:
var capabilities = new ClientCapabilities
{
ClientName = "Living Room", // Display name
ProductName = "MySpeaker Pro", // Product identifier
Manufacturer = "Acme Audio", // Your company
SoftwareVersion = "2.1.0", // App version
MacAddress = "aa:bb:cc:dd:ee:ff" // NIC MAC, lowercase colon-separated
};
All fields are optional and omitted from the protocol if null.
Player Timing & Static Delay
Players report timing requirements so the server can schedule audio far enough ahead to avoid
buffer underruns and start-of-stream truncation (per the Sendspin spec's player timing
capabilities). These are advertised in every client/state message:
var capabilities = new ClientCapabilities
{
// Minimum startup lead time: codec init, decode warmup, backend buffering, DAC latency.
// The server schedules the first chunk at least this far ahead after a stream start/restart.
RequiredLeadTimeMs = 200, // default: 200 ms (conservative LAN starting point)
// Minimum ongoing buffer to absorb network jitter (primarily for live streams).
MinBufferMs = 150, // default: 150 ms
// Whether to accept the server's set_static_delay command (advertised in client/state).
SupportsSetStaticDelay = true,
};
Report the lowest values that reliably avoid truncation/underruns for your device and network —
larger for remote or high-latency links, smaller for stable LAN. Do not fold static_delay_ms
into these values; the server applies static delay separately. For empirical tuning, the audio
pipeline exposes measured latency (e.g. AudioPipeline.DetectedOutputLatencyMs).
If conditions change at runtime (e.g. a link-type change, or a measured lead time after warmup),
update the values and the SDK re-reports client/state:
await client.UpdateTimingAsync(requiredLeadTimeMs: 120, minBufferMs: 80);
Debounce these updates yourself — report only sustained changes, not transient fluctuations.
Persisting static delay across restarts
static_delay_ms compensates for hardware delay beyond the audio port (external speakers,
amplifiers) and must persist across reboots and reconnections. Because the SDK is a library and
cannot choose where to store it, implement IStaticDelayStore and pass it to the client. The SDK
loads on connect (before the first client/state) and saves whenever the delay changes (via a
set_static_delay command or a GroupSync offset):
public sealed class FileStaticDelayStore : IStaticDelayStore
{
// Use InvariantCulture so the value round-trips regardless of the host's locale.
public double? Load() => File.Exists(path)
? double.Parse(File.ReadAllText(path), CultureInfo.InvariantCulture)
: null;
public void Save(double staticDelayMs)
=> File.WriteAllText(path, staticDelayMs.ToString(CultureInfo.InvariantCulture));
}
var client = new SendspinClientService(
logger, connection, clockSync, capabilities,
audioPipeline: pipeline,
staticDelayStore: new FileStaticDelayStore());
When no store is supplied, behavior is unchanged: the embedder re-supplies the delay on each connect.
Artwork
Artwork clients support 1–4 independent channels (e.g. album art on one display, artist photos on another). Each channel has its own source, format, and maximum size. Configure them in capabilities:
var capabilities = new ClientCapabilities
{
ArtworkChannels = new()
{
new() { Source = ArtworkSources.Album, Format = "jpeg", MediaWidth = 512, MediaHeight = 512 }, // channel 0
new() { Source = ArtworkSources.Artist, Format = "png", MediaWidth = 256, MediaHeight = 256 }, // channel 1
}
};
Images arrive per channel, with the display timestamp and channel number:
client.ArtworkReceived += (_, e) =>
{
// e.Channel (0-3), e.Timestamp (server clock, microseconds), e.ImageData (jpeg/png/bmp bytes)
displays[e.Channel].Show(e.ImageData);
};
client.ArtworkCleared += (_, e) => displays[e.Channel].Clear(); // empty binary message = clear that channel
Change or disable a channel at runtime without reconnecting (server replies with a new stream/start):
// Switch channel 1 to artist art at a new size:
await client.RequestArtworkFormatAsync(channel: 1, source: ArtworkSources.Artist, mediaWidth: 400, mediaHeight: 400);
// Disable channel 1 (server stops sending it); re-enable later by requesting a real source again:
await client.RequestArtworkFormatAsync(channel: 1, source: ArtworkSources.None);
Color
Clients with the color role receive a palette derived from the current audio — useful for ambient lighting, screen backgrounds, or UI theming. Colors arrive via server/state and are merged onto GroupState.Colors; subscribe to ColorChanged to react:
client.ColorChanged += (_, palette) =>
{
// RgbColor? per role; null until the server provides it (or after it clears it).
if (palette.BackgroundDark is { } bg) lights.SetBackground(bg.R, bg.G, bg.B);
if (palette.Primary is { } primary) ui.Accent = primary;
};
Available colors: BackgroundDark, BackgroundLight, Primary, Accent, OnDark, OnLight, plus a Timestamp (server clock, µs). The server guarantees WCAG 4.5:1 contrast ratios between the background/on-color pairs — clients use the values directly and do no contrast math.
Updates are deltas: a color absent from an update is left unchanged, an explicit null clears it, and a value updates it. The role is enabled by default (color@v1 in ClientCapabilities.Roles); remove it to opt out.
Visualizer
Clients with the visualizer@v1 role receive real-time audio features for music visualization. Six feature types are available: loudness, f_peak (dominant frequency + amplitude), spectrum (display-binned FFT), beat, peak (energy onsets), and pitch. The role is opt-in — set VisualizerSupport and add visualizer@v1 to Roles:
var capabilities = new ClientCapabilities
{
Roles = { "player@v1", "visualizer@v1" },
VisualizerSupport = new VisualizerSupport
{
BufferCapacity = 65536,
RateMax = 30, // max frames/sec
Types = new() { VisualizerTypes.Loudness, VisualizerTypes.Spectrum, VisualizerTypes.Beat },
// Required when Spectrum is requested:
Spectrum = new VisualizerSpectrum { NDispBins = 32, Scale = "log", FMin = 20, FMax = 16000 },
},
};
Each binary message carries one feature type; subscribe to VisualizationReceived and read the populated field:
client.VisualizationReceived += (_, frame) =>
{
if (frame.Loudness is { } loud) meter.Level = loud / 65535.0;
if (frame.Spectrum is { } bins) bars.Update(bins); // NDispBins values
if (frame.IsDownbeat is { } down) pulse.Beat(strong: down);
if (frame.PitchMidi is { } note) label.Text = $"MIDI {note:F1}"; // pitch is Q8.8 → fractional MIDI
};
Spectrum frames are validated against the negotiated NDispBins from the latest stream/start; malformed frames are dropped (no event). Renegotiate at runtime with RequestVisualizerFormatAsync(...).
Note:
visualizer@v1follows the aiosendspin reference implementation, which is ahead of the formal protocol spec. The wire format may still evolve. The role degrades gracefully while it matures: it is opt-in (off by default), frames that don't match the negotiated/expected format are dropped (logged atTrace) rather than throwing, and a misbehavingVisualizationReceivedhandler is isolated so it can't disrupt audio or artwork.
NativeAOT Support
Since v7.0.0, the SDK is fully compatible with NativeAOT deployment and IL trimming. This means you can publish your Sendspin player as a single native executable with no .NET runtime dependency — ideal for embedded devices, containers, or minimal Linux installations.
<PropertyGroup>
<PublishAot>true</PublishAot>
</PropertyGroup>
dotnet publish -c Release -r linux-x64
# Produces a single native binary (~15-25MB depending on dependencies)
How it works: The SDK uses source-generated System.Text.Json serialization (no runtime reflection) and built-in .NET WebSocket APIs. All public types are annotated with IsAotCompatible and IsTrimmable to ensure the .NET build analyzers catch any regressions.
Your code: If your IAudioPlayer implementation also avoids reflection, the entire stack will be AOT-safe. Most audio libraries (SDL2, OpenAL, PipeWire bindings) work fine with NativeAOT.
Migration Guide
Upgrading to v7.0.0
Breaking change: SendspinListener.ServerConnected event parameter type changed.
// Before (v6.x):
listener.ServerConnected += (sender, fleckConnection) => { /* Fleck.IWebSocketConnection */ };
// After (v7.0+):
listener.ServerConnected += (sender, wsConnection) => { /* WebSocketClientConnection */ };
No changes needed if you only use SendspinHostService or SendspinClientService (most consumers).
Upgrading to v5.0.0
Breaking change: Sync correction is now external. The SDK reports error; you apply correction.
Before (v4.x and earlier):
// SDK applied correction internally
var read = buffer.Read(samples, currentTime);
buffer.TargetPlaybackRateChanged += rate => resampler.Rate = rate;
After (v5.0+):
// Create correction provider
var correctionProvider = new SyncCorrectionCalculator(
SyncCorrectionOptions.Default, sampleRate, channels);
// Read raw samples (no internal correction)
var read = buffer.ReadRaw(samples, offset, count, currentTime);
// Update and apply correction externally
correctionProvider.UpdateFromSyncError(
buffer.SyncErrorMicroseconds,
buffer.SmoothedSyncErrorMicroseconds);
// Subscribe to rate changes
correctionProvider.CorrectionChanged += p => resampler.Rate = p.TargetPlaybackRate;
// Notify buffer of any drops/inserts for accurate tracking
buffer.NotifyExternalCorrection(droppedCount, insertedCount);
Benefits:
- Browser apps can use native
playbackRate(WSOLA) - Windows apps can choose WDL resampler, SoundTouch, or drop/insert
- Linux apps can use ALSA hardware rate adjustment
- Testability: correction logic is isolated
Upgrading to v3.0.0
Breaking change: IClockSynchronizer requires HasMinimalSync property.
// Add to custom IClockSynchronizer implementations:
public bool HasMinimalSync => MeasurementCount >= 2;
Upgrading to v2.0.0
HardwareLatencyMsremoved - No action needed, latency handled automaticallyIAudioPipeline.SwitchDeviceAsync()required - Implement for device switchingIAudioPlayer.SwitchDeviceAsync()required - Implement in your audio player
Example Projects
See the Windows client for a complete WPF implementation using NAudio/WASAPI with external sync correction.
License
MIT License - see LICENSE for details.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Concentus (>= 2.2.2)
- Makaretu.Dns.Multicast (>= 0.27.0)
- Microsoft.Extensions.Logging.Abstractions (>= 8.0.2)
- Zeroconf (>= 3.7.16)
-
net8.0
- Concentus (>= 2.2.2)
- Makaretu.Dns.Multicast (>= 0.27.0)
- Microsoft.Extensions.Logging.Abstractions (>= 8.0.2)
- Zeroconf (>= 3.7.16)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 9.3.0 | 31 | 8/26/2026 |
| 9.2.0 | 248 | 8/8/2026 |
| 9.1.0 | 383 | 6/17/2026 |
| 9.0.6 | 112 | 6/17/2026 |
| 9.0.5 | 140 | 6/16/2026 |
| 9.0.4 | 151 | 6/15/2026 |
| 9.0.3 | 132 | 6/12/2026 |
| 9.0.2 | 125 | 6/12/2026 |
| 9.0.1 | 112 | 6/12/2026 |
| 9.0.0 | 154 | 6/5/2026 |
| 8.0.0 | 155 | 5/5/2026 |
| 7.4.0 | 120 | 4/17/2026 |
| 7.3.0 | 267 | 3/7/2026 |
| 7.2.1 | 136 | 3/4/2026 |
| 7.1.1 | 120 | 3/4/2026 |
| 7.1.0 | 115 | 3/4/2026 |
| 7.0.0 | 111 | 3/4/2026 |
| 6.3.6 | 121 | 3/4/2026 |
| 6.3.5 | 494 | 2/3/2026 |
v9.3.0 - Sync Conformance and Stability (9.x legacy line):
A curated backport for apps that cannot take 10.0's breaking renames. The public
API surface is unchanged from 9.2.0; every fix below is carried on internal
members where the upstream change added public ones.
SYNC CONFORMANCE - THESE CHANGE CORRECTION DEFAULTS:
- SyncCorrectionOptions.MaxSpeedCorrection defaults to 0.005 (0.5%), down from
0.02 (2%). The spec caps effective playback speed at +/-0.5% (a MUST), and the
cap is a fleet-homogeneity contract: every player in a group must recover from
a disturbance at the same bounded rate. A larger configured value is now
clamped where correction is applied, with a warning, rather than honoured.
- SyncCorrectionOptions.DeadbandMicroseconds defaults to 100 us, down from
1_000. The old band sat exactly on the spec's +/-1 ms MUST floor, which makes
the +/-0.5 ms SHOULD target unreachable by construction.
- New one-shot resynchronization tier above 5 ms: the excess is skipped or
padded in a single discontinuity, which the spec describes and exempts from
the speed cap. Grinding a 50 ms error out at 0.5% takes 10 seconds, during
which the player audibly trails the rest of the group. Errors in that range
now snap instead of being ground out - audible as one splice rather than as
sustained misalignment.
- Clock synchronization matches the reference filter's constants and rules:
reference process noise, non-positive round trips dropped rather than floored,
T1/T4 stamped at the transport boundary, the reference probe cadence and
timeout, and a converging-vs-steady burst interval keyed on convergence. A
client on a good network now converges in seconds instead of twenty-odd.
- Decoder buffers are sized for the spec's 150 ms maximum chunk (PCM was sized
for 50 ms, FLAC for a fixed 8192 frames). A chunk that still overruns decodes
its legal prefix and reports the loss instead of truncating silently.
- ClientCapabilities.BufferCapacity is derived from the decoded buffer and the
most compressed advertised codec instead of defaulting to a flat 32 MB. The
spec lets a server fill toward whatever is advertised, so the old default
licensed it to queue far more audio than the client could hold. A configured
value above what the buffer can hold is clamped, with a warning.
- ITimedAudioBuffer.NotifyExternalCorrection is stats-only. It also moved the
read cursor that ReadRaw had already advanced, so the reported sync error
converged at twice the physical correction: the metric read near zero while
the player stayed about half the drift out of the group.
STABILITY:
- The audio pipeline's start, stop, device switch and dispose are serialized.
Interleaved, a track boundary could have one call's teardown dispose what
another was still building, ending in Error and silence until the next stream.
- stream/start, stream/end and stream/clear take effect in the order they
arrived, so an end's teardown can no longer land after the following start.
- The decoder reset requested by stream/clear is taken on the decoding thread
rather than the requesting one, and a re-anchor no longer resets the decoder
at all. The pipeline can no longer be left reporting Playing over an empty
ring, which was permanent silence for the rest of the stream.
- A chunk arriving mid-start can no longer overtake the queued ones or decode
through the pipeline's scratch buffer at the same time as one being drained.
- A peer message carrying JSON null where the protocol declares a value is
rejected and closes the connection deliberately, instead of being half applied
or handed to app event subscribers.
FORWARD COMPATIBILITY:
- server/command accepts set_output_delay and output_delay_ms alongside
set_static_delay and static_delay_ms, so a client fielded now keeps working
when a server adopts the rename. Outbound naming is unchanged.
----
Earlier release notes: https://github.com/Sendspin/sendspin-dotnet/releases