diff --git a/src/Sendspin.SDK/Client/ISendSpinClient.cs b/src/Sendspin.SDK/Client/ISendSpinClient.cs
index 45d1675..05f035c 100644
--- a/src/Sendspin.SDK/Client/ISendSpinClient.cs
+++ b/src/Sendspin.SDK/Client/ISendSpinClient.cs
@@ -75,7 +75,8 @@ public interface ISendspinClient : IAsyncDisposable
///
/// Current volume level (0-100).
/// Current mute state.
- Task SendPlayerStateAsync(int volume, bool muted);
+ /// Static delay in milliseconds for group sync calibration.
+ Task SendPlayerStateAsync(int volume, bool muted, double staticDelayMs = 0.0);
///
/// Clears the audio buffer, causing the pipeline to restart buffering.
@@ -107,6 +108,12 @@ public interface ISendspinClient : IAsyncDisposable
///
event EventHandler? ArtworkReceived;
+ ///
+ /// Event raised when artwork is cleared (empty artwork binary message).
+ /// The server sends an empty payload to signal "no artwork available".
+ ///
+ event EventHandler? ArtworkCleared;
+
///
/// Event raised when the clock synchronizer first converges to a stable estimate.
/// This indicates that the client is ready for sample-accurate synchronized playback.
diff --git a/src/Sendspin.SDK/Client/SendSpinClient.cs b/src/Sendspin.SDK/Client/SendSpinClient.cs
index 20425a4..42a0c22 100644
--- a/src/Sendspin.SDK/Client/SendSpinClient.cs
+++ b/src/Sendspin.SDK/Client/SendSpinClient.cs
@@ -83,6 +83,7 @@ public sealed class SendspinClientService : ISendspinClient
public event EventHandler? GroupStateChanged;
public event EventHandler? PlayerStateChanged;
public event EventHandler? ArtworkReceived;
+ public event EventHandler? ArtworkCleared;
public event EventHandler? ClockSyncConverged;
public event EventHandler? SyncOffsetApplied;
@@ -288,12 +289,13 @@ public async Task SetVolumeAsync(int volume)
}
///
- public async Task SendPlayerStateAsync(int volume, bool muted)
+ public async Task SendPlayerStateAsync(int volume, bool muted, double staticDelayMs = 0.0)
{
var clampedVolume = Math.Clamp(volume, 0, 100);
- var stateMessage = ClientStateMessage.CreateSynchronized(clampedVolume, muted);
+ var stateMessage = ClientStateMessage.CreateSynchronized(clampedVolume, muted, staticDelayMs);
- _logger.LogDebug("Sending player state: Volume={Volume}, Muted={Muted}", clampedVolume, muted);
+ _logger.LogDebug("Sending player state: Volume={Volume}, Muted={Muted}, StaticDelay={StaticDelay}ms",
+ clampedVolume, muted, staticDelayMs);
await _connection.SendMessageAsync(stateMessage);
}
@@ -443,7 +445,8 @@ private async Task SendInitialClientStateAsync()
// Send the current player state (initialized from capabilities)
var stateMessage = ClientStateMessage.CreateSynchronized(
volume: _playerState.Volume,
- muted: _playerState.Muted);
+ muted: _playerState.Muted,
+ staticDelayMs: _clockSynchronizer.StaticDelayMs);
var stateJson = MessageSerializer.Serialize(stateMessage);
_logger.LogInformation("Sending initial client/state:\n{Json}", stateJson);
await _connection.SendMessageAsync(stateMessage);
@@ -849,7 +852,7 @@ private void HandleServerCommand(string json)
///
private async Task SendPlayerStateAckAsync()
{
- await SendPlayerStateAsync(_playerState.Volume, _playerState.Muted);
+ await SendPlayerStateAsync(_playerState.Volume, _playerState.Muted, _clockSynchronizer.StaticDelayMs);
}
///
@@ -1030,8 +1033,16 @@ private void OnBinaryMessageReceived(object? sender, ReadOnlyMemory data)
var artwork = BinaryMessageParser.ParseArtworkChunk(data.Span);
if (artwork is not null)
{
- _logger.LogDebug("Artwork received: {Length} bytes", artwork.ImageData.Length);
- ArtworkReceived?.Invoke(this, artwork.ImageData);
+ if (artwork.ImageData.Length == 0)
+ {
+ _logger.LogDebug("Artwork cleared (empty payload)");
+ ArtworkCleared?.Invoke(this, EventArgs.Empty);
+ }
+ else
+ {
+ _logger.LogDebug("Artwork received: {Length} bytes", artwork.ImageData.Length);
+ ArtworkReceived?.Invoke(this, artwork.ImageData);
+ }
}
break;
diff --git a/src/Sendspin.SDK/Client/SendSpinHostService.cs b/src/Sendspin.SDK/Client/SendSpinHostService.cs
index 523ce01..33b1b34 100644
--- a/src/Sendspin.SDK/Client/SendSpinHostService.cs
+++ b/src/Sendspin.SDK/Client/SendSpinHostService.cs
@@ -93,6 +93,11 @@ public IReadOnlyList ConnectedServers
///
public event EventHandler? ArtworkReceived;
+ ///
+ /// Raised when artwork is cleared (empty artwork binary message from server).
+ ///
+ public event EventHandler? ArtworkCleared;
+
///
/// Raised when the last-played server ID changes.
/// Consumers should persist this value so it survives app restarts.
@@ -308,6 +313,7 @@ private async void OnServerConnected(object? sender, WebSocketClientConnection w
};
client.PlayerStateChanged += (s, p) => PlayerStateChanged?.Invoke(this, p);
client.ArtworkReceived += (s, data) => ArtworkReceived?.Invoke(this, data);
+ client.ArtworkCleared += (s, e) => ArtworkCleared?.Invoke(this, EventArgs.Empty);
// Start the connection (begins receive loop)
await connection.StartAsync();
@@ -669,7 +675,11 @@ public async Task SendCommandAsync(string command, Dictionary? p
///
/// Sends the current player state (volume, muted) to a specific server or all connected servers.
///
- public async Task SendPlayerStateAsync(int volume, bool muted, string? serverId = null)
+ /// Current volume level (0-100).
+ /// Current mute state.
+ /// Static delay in milliseconds for group sync calibration.
+ /// Target server ID, or null for all servers.
+ public async Task SendPlayerStateAsync(int volume, bool muted, double staticDelayMs = 0.0, string? serverId = null)
{
List clients;
lock (_connectionsLock)
@@ -693,7 +703,7 @@ public async Task SendPlayerStateAsync(int volume, bool muted, string? serverId
foreach (var client in clients)
{
- await client.SendPlayerStateAsync(volume, muted);
+ await client.SendPlayerStateAsync(volume, muted, staticDelayMs);
}
}
diff --git a/src/Sendspin.SDK/Protocol/Messages/ClientStateMessage.cs b/src/Sendspin.SDK/Protocol/Messages/ClientStateMessage.cs
index 2efa221..be4bb4b 100644
--- a/src/Sendspin.SDK/Protocol/Messages/ClientStateMessage.cs
+++ b/src/Sendspin.SDK/Protocol/Messages/ClientStateMessage.cs
@@ -19,7 +19,10 @@ public sealed class ClientStateMessage : IMessageWithPayload
/// Creates a synchronized state message with player volume/mute.
/// This should be sent immediately after receiving server/hello.
///
- public static ClientStateMessage CreateSynchronized(int volume = 100, bool muted = false)
+ /// Player volume (0-100).
+ /// Whether the player is muted.
+ /// Static delay in milliseconds for group sync calibration.
+ public static ClientStateMessage CreateSynchronized(int volume = 100, bool muted = false, double staticDelayMs = 0.0)
{
return new ClientStateMessage
{
@@ -29,7 +32,8 @@ public static ClientStateMessage CreateSynchronized(int volume = 100, bool muted
Player = new PlayerStatePayload
{
Volume = volume,
- Muted = muted
+ Muted = muted,
+ StaticDelayMs = staticDelayMs
}
}
};
@@ -115,4 +119,13 @@ public sealed class PlayerStatePayload
[JsonPropertyName("error")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Error { get; init; }
+
+ ///
+ /// Static delay in milliseconds configured for this player.
+ /// Used by the server during GroupSync calibration to compensate for
+ /// device audio output latency across the group.
+ ///
+ [JsonPropertyName("static_delay_ms")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public double StaticDelayMs { get; init; }
}
diff --git a/src/Sendspin.SDK/Sendspin.SDK.csproj b/src/Sendspin.SDK/Sendspin.SDK.csproj
index d028a18..baaea7b 100644
--- a/src/Sendspin.SDK/Sendspin.SDK.csproj
+++ b/src/Sendspin.SDK/Sendspin.SDK.csproj
@@ -8,7 +8,7 @@
Sendspin.SDK
- 7.0.0
+ 7.1.0
Sendspin Contributors
Sendspin
Sendspin SDK
@@ -20,6 +20,21 @@
MIT
README.md
+v7.1.0 - Protocol Compliance (aiosendspin audit):
+
+New Features:
+- PlayerStatePayload.StaticDelayMs: Reports configured static delay to server
+ for GroupSync calibration. Server uses this to compensate for device output
+ latency when calculating group-wide sync offsets.
+- ISendspinClient.ArtworkCleared event: Raised when server sends empty artwork
+ binary payload (type 8-11, 0-byte data) to signal "no artwork available".
+ Previously, empty payloads were passed through as empty byte arrays.
+- SendPlayerStateAsync now accepts optional staticDelayMs parameter (default 0.0)
+- SendspinHostService.ArtworkCleared event: Forwarded from child clients
+
+Bug Fixes:
+- Empty artwork binary messages no longer treated as valid image data
+
v7.0.0 - NativeAOT Support:
BREAKING CHANGES:
diff --git a/src/SendspinClient/Configuration/AudioFormatBuilder.cs b/src/SendspinClient/Configuration/AudioFormatBuilder.cs
index a913dfa..936b31e 100644
--- a/src/SendspinClient/Configuration/AudioFormatBuilder.cs
+++ b/src/SendspinClient/Configuration/AudioFormatBuilder.cs
@@ -32,10 +32,11 @@ public static List BuildFormats(
var formats = new List();
var sampleRate = capabilities.NativeSampleRate;
- // WASAPI MixFormat reports 32 for 32-bit float (Windows Audio Engine internal format).
- // Cap at 24-bit since that's the max standard hi-res PCM bit depth.
- // When WASAPI reports 32-bit, the device can handle any format via Windows resampling.
- var bitDepth = Math.Min(capabilities.NativeBitDepth, 24);
+ // Use the device's native bit depth as-is. WASAPI MixFormat reports 32 for devices
+ // using 32-bit float internally, but the server sends 32-bit signed integer PCM.
+ // This works because NAudio's WasapiOut handles the conversion internally, and the
+ // SDK's PcmDecoder already reads 32-bit via ReadInt32LittleEndian.
+ var bitDepth = capabilities.NativeBitDepth;
// Preferred codec first at native resolution
if (preferredCodec == "flac")
diff --git a/src/SendspinClient/SendspinClient.csproj b/src/SendspinClient/SendspinClient.csproj
index fd621d0..6880024 100644
--- a/src/SendspinClient/SendspinClient.csproj
+++ b/src/SendspinClient/SendspinClient.csproj
@@ -10,7 +10,7 @@
Resources\Icons\sendspinTray.ico
- 1.11.0
+ 1.12.0