Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/Sendspin.SDK/Client/ISendSpinClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ public interface ISendspinClient : IAsyncDisposable
/// </summary>
/// <param name="volume">Current volume level (0-100).</param>
/// <param name="muted">Current mute state.</param>
Task SendPlayerStateAsync(int volume, bool muted);
/// <param name="staticDelayMs">Static delay in milliseconds for group sync calibration.</param>
Task SendPlayerStateAsync(int volume, bool muted, double staticDelayMs = 0.0);

/// <summary>
/// Clears the audio buffer, causing the pipeline to restart buffering.
Expand Down Expand Up @@ -107,6 +108,12 @@ public interface ISendspinClient : IAsyncDisposable
/// </summary>
event EventHandler<byte[]>? ArtworkReceived;

/// <summary>
/// Event raised when artwork is cleared (empty artwork binary message).
/// The server sends an empty payload to signal "no artwork available".
/// </summary>
event EventHandler? ArtworkCleared;

/// <summary>
/// Event raised when the clock synchronizer first converges to a stable estimate.
/// This indicates that the client is ready for sample-accurate synchronized playback.
Expand Down
25 changes: 18 additions & 7 deletions src/Sendspin.SDK/Client/SendSpinClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ public sealed class SendspinClientService : ISendspinClient
public event EventHandler<GroupState>? GroupStateChanged;
public event EventHandler<PlayerState>? PlayerStateChanged;
public event EventHandler<byte[]>? ArtworkReceived;
public event EventHandler? ArtworkCleared;
public event EventHandler<ClockSyncStatus>? ClockSyncConverged;
public event EventHandler<SyncOffsetEventArgs>? SyncOffsetApplied;

Expand Down Expand Up @@ -288,12 +289,13 @@ public async Task SetVolumeAsync(int volume)
}

/// <inheritdoc/>
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);
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -849,7 +852,7 @@ private void HandleServerCommand(string json)
/// </summary>
private async Task SendPlayerStateAckAsync()
{
await SendPlayerStateAsync(_playerState.Volume, _playerState.Muted);
await SendPlayerStateAsync(_playerState.Volume, _playerState.Muted, _clockSynchronizer.StaticDelayMs);
}

/// <summary>
Expand Down Expand Up @@ -1030,8 +1033,16 @@ private void OnBinaryMessageReceived(object? sender, ReadOnlyMemory<byte> 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;

Expand Down
14 changes: 12 additions & 2 deletions src/Sendspin.SDK/Client/SendSpinHostService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ public IReadOnlyList<ConnectedServerInfo> ConnectedServers
/// </summary>
public event EventHandler<byte[]>? ArtworkReceived;

/// <summary>
/// Raised when artwork is cleared (empty artwork binary message from server).
/// </summary>
public event EventHandler? ArtworkCleared;

/// <summary>
/// Raised when the last-played server ID changes.
/// Consumers should persist this value so it survives app restarts.
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -669,7 +675,11 @@ public async Task SendCommandAsync(string command, Dictionary<string, object>? p
/// <summary>
/// Sends the current player state (volume, muted) to a specific server or all connected servers.
/// </summary>
public async Task SendPlayerStateAsync(int volume, bool muted, string? serverId = null)
/// <param name="volume">Current volume level (0-100).</param>
/// <param name="muted">Current mute state.</param>
/// <param name="staticDelayMs">Static delay in milliseconds for group sync calibration.</param>
/// <param name="serverId">Target server ID, or null for all servers.</param>
public async Task SendPlayerStateAsync(int volume, bool muted, double staticDelayMs = 0.0, string? serverId = null)
{
List<SendspinClientService> clients;
lock (_connectionsLock)
Expand All @@ -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);
}
}

Expand Down
17 changes: 15 additions & 2 deletions src/Sendspin.SDK/Protocol/Messages/ClientStateMessage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ public sealed class ClientStateMessage : IMessageWithPayload<ClientStatePayload>
/// Creates a synchronized state message with player volume/mute.
/// This should be sent immediately after receiving server/hello.
/// </summary>
public static ClientStateMessage CreateSynchronized(int volume = 100, bool muted = false)
/// <param name="volume">Player volume (0-100).</param>
/// <param name="muted">Whether the player is muted.</param>
/// <param name="staticDelayMs">Static delay in milliseconds for group sync calibration.</param>
public static ClientStateMessage CreateSynchronized(int volume = 100, bool muted = false, double staticDelayMs = 0.0)
{
return new ClientStateMessage
{
Expand All @@ -29,7 +32,8 @@ public static ClientStateMessage CreateSynchronized(int volume = 100, bool muted
Player = new PlayerStatePayload
{
Volume = volume,
Muted = muted
Muted = muted,
StaticDelayMs = staticDelayMs
}
}
};
Expand Down Expand Up @@ -115,4 +119,13 @@ public sealed class PlayerStatePayload
[JsonPropertyName("error")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Error { get; init; }

/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("static_delay_ms")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public double StaticDelayMs { get; init; }
}
17 changes: 16 additions & 1 deletion src/Sendspin.SDK/Sendspin.SDK.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

<!-- NuGet Package Metadata -->
<PackageId>Sendspin.SDK</PackageId>
<Version>7.0.0</Version>
<Version>7.1.0</Version>
<Authors>Sendspin Contributors</Authors>
<Company>Sendspin</Company>
<Product>Sendspin SDK</Product>
Expand All @@ -20,6 +20,21 @@
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageReleaseNotes>
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:
Expand Down
9 changes: 5 additions & 4 deletions src/SendspinClient/Configuration/AudioFormatBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,11 @@ public static List<AudioFormat> BuildFormats(
var formats = new List<AudioFormat>();
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")
Expand Down
2 changes: 1 addition & 1 deletion src/SendspinClient/SendspinClient.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<ApplicationIcon>Resources\Icons\sendspinTray.ico</ApplicationIcon>

<!-- Version info - set via -p:Version= during CI builds -->
<Version>1.11.0</Version>
<Version>1.12.0</Version>
<!--
AssemblyVersion/FileVersion must be purely numeric (major.minor.build.revision).
For prerelease versions like "0.0.0-dev.xxx", we need separate numeric versions.
Expand Down
18 changes: 16 additions & 2 deletions src/SendspinClient/ViewModels/MainViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
using Microsoft.Extensions.Logging;
using NAudio.CoreAudioApi;
using SendspinClient.Configuration;
using SendspinClient.Models;

Check warning on line 14 in src/SendspinClient/ViewModels/MainViewModel.cs

View workflow job for this annotation

GitHub Actions / build

Using directives should be ordered alphabetically by the namespaces. (https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1210.md)
using Sendspin.SDK.Audio;
using Sendspin.SDK.Client;
using Sendspin.SDK.Connection;
Expand Down Expand Up @@ -468,6 +468,7 @@
_hostService.GroupStateChanged += OnGroupStateChanged;
_hostService.PlayerStateChanged += OnPlayerStateChanged;
_hostService.ArtworkReceived += OnArtworkReceived;
_hostService.ArtworkCleared += OnArtworkCleared;
_hostService.LastPlayedServerIdChanged += OnLastPlayedServerIdChanged;

// Subscribe to server discovery events (client-initiated mode - primary)
Expand Down Expand Up @@ -691,17 +692,19 @@
/// </summary>
private async Task SendPlayerStateToActiveClientAsync(int volume, bool muted)
{
var staticDelay = SettingsStaticDelayMs;

// Prefer manual client (discovery/manual connection mode)
if (_manualClient?.ConnectionState == ConnectionState.Connected)
{
_logger.LogDebug("Sending player state via manual client: Volume={Volume}, Muted={Muted}", volume, muted);
await _manualClient.SendPlayerStateAsync(volume, muted);
await _manualClient.SendPlayerStateAsync(volume, muted, staticDelay);
}
// Fall back to host service (server-initiated connection mode)
else if (ConnectedServers.Count > 0)
{
_logger.LogDebug("Sending player state via host service: Volume={Volume}, Muted={Muted}", volume, muted);
await _hostService.SendPlayerStateAsync(volume, muted);
await _hostService.SendPlayerStateAsync(volume, muted, staticDelay);
}
else
{
Expand Down Expand Up @@ -729,6 +732,7 @@
_manualClient.GroupStateChanged += OnManualClientGroupStateChanged;
_manualClient.PlayerStateChanged += OnManualClientPlayerStateChanged;
_manualClient.ArtworkReceived += OnManualClientArtworkReceived;
_manualClient.ArtworkCleared += OnArtworkCleared;
}

/// <summary>
Expand Down Expand Up @@ -852,6 +856,7 @@
_manualClient.GroupStateChanged -= OnManualClientGroupStateChanged;
_manualClient.PlayerStateChanged -= OnManualClientPlayerStateChanged;
_manualClient.ArtworkReceived -= OnManualClientArtworkReceived;
_manualClient.ArtworkCleared -= OnArtworkCleared;

try
{
Expand Down Expand Up @@ -1197,6 +1202,15 @@
});
}

private void OnArtworkCleared(object? sender, EventArgs e)
{
App.Current.Dispatcher.Invoke(() =>
{
AlbumArtwork = null;
_logger.LogDebug("Artwork cleared (no artwork available)");
});
}

/// <summary>
/// Handles player state changes from server/command messages (server-initiated mode).
/// This updates the player's actual volume/mute state, not the group average.
Expand Down
Loading