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