diff --git a/SendspinClient.sln b/SendspinClient.sln
index 6f495a6..1e75a1e 100644
--- a/SendspinClient.sln
+++ b/SendspinClient.sln
@@ -1,4 +1,4 @@
-
+
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
@@ -9,12 +9,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SendspinClient.Services", "
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SendspinClient", "src\SendspinClient\SendspinClient.csproj", "{BEDF6E56-4B93-49E8-87D2-FC5E9F99F25F}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sendspin.SDK", "src\Sendspin.SDK\Sendspin.SDK.csproj", "{F0A64EE7-53B8-4F0A-A7C8-7730607D42EB}"
-EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sendspin.SDK.Tests", "tests\Sendspin.SDK.Tests\Sendspin.SDK.Tests.csproj", "{861315FB-857D-4F44-99B9-78D53B57EF3B}"
-EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -49,30 +43,6 @@ Global
{BEDF6E56-4B93-49E8-87D2-FC5E9F99F25F}.Release|x64.Build.0 = Release|Any CPU
{BEDF6E56-4B93-49E8-87D2-FC5E9F99F25F}.Release|x86.ActiveCfg = Release|Any CPU
{BEDF6E56-4B93-49E8-87D2-FC5E9F99F25F}.Release|x86.Build.0 = Release|Any CPU
- {F0A64EE7-53B8-4F0A-A7C8-7730607D42EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {F0A64EE7-53B8-4F0A-A7C8-7730607D42EB}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {F0A64EE7-53B8-4F0A-A7C8-7730607D42EB}.Debug|x64.ActiveCfg = Debug|Any CPU
- {F0A64EE7-53B8-4F0A-A7C8-7730607D42EB}.Debug|x64.Build.0 = Debug|Any CPU
- {F0A64EE7-53B8-4F0A-A7C8-7730607D42EB}.Debug|x86.ActiveCfg = Debug|Any CPU
- {F0A64EE7-53B8-4F0A-A7C8-7730607D42EB}.Debug|x86.Build.0 = Debug|Any CPU
- {F0A64EE7-53B8-4F0A-A7C8-7730607D42EB}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {F0A64EE7-53B8-4F0A-A7C8-7730607D42EB}.Release|Any CPU.Build.0 = Release|Any CPU
- {F0A64EE7-53B8-4F0A-A7C8-7730607D42EB}.Release|x64.ActiveCfg = Release|Any CPU
- {F0A64EE7-53B8-4F0A-A7C8-7730607D42EB}.Release|x64.Build.0 = Release|Any CPU
- {F0A64EE7-53B8-4F0A-A7C8-7730607D42EB}.Release|x86.ActiveCfg = Release|Any CPU
- {F0A64EE7-53B8-4F0A-A7C8-7730607D42EB}.Release|x86.Build.0 = Release|Any CPU
- {861315FB-857D-4F44-99B9-78D53B57EF3B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {861315FB-857D-4F44-99B9-78D53B57EF3B}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {861315FB-857D-4F44-99B9-78D53B57EF3B}.Debug|x64.ActiveCfg = Debug|Any CPU
- {861315FB-857D-4F44-99B9-78D53B57EF3B}.Debug|x64.Build.0 = Debug|Any CPU
- {861315FB-857D-4F44-99B9-78D53B57EF3B}.Debug|x86.ActiveCfg = Debug|Any CPU
- {861315FB-857D-4F44-99B9-78D53B57EF3B}.Debug|x86.Build.0 = Debug|Any CPU
- {861315FB-857D-4F44-99B9-78D53B57EF3B}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {861315FB-857D-4F44-99B9-78D53B57EF3B}.Release|Any CPU.Build.0 = Release|Any CPU
- {861315FB-857D-4F44-99B9-78D53B57EF3B}.Release|x64.ActiveCfg = Release|Any CPU
- {861315FB-857D-4F44-99B9-78D53B57EF3B}.Release|x64.Build.0 = Release|Any CPU
- {861315FB-857D-4F44-99B9-78D53B57EF3B}.Release|x86.ActiveCfg = Release|Any CPU
- {861315FB-857D-4F44-99B9-78D53B57EF3B}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -80,7 +50,5 @@ Global
GlobalSection(NestedProjects) = preSolution
{5D7F0309-FDB6-48C8-871D-903BE1193B2B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{BEDF6E56-4B93-49E8-87D2-FC5E9F99F25F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
- {F0A64EE7-53B8-4F0A-A7C8-7730607D42EB} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
- {861315FB-857D-4F44-99B9-78D53B57EF3B} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal
diff --git a/claude.md b/claude.md
index 728a1e1..ed8cb62 100644
--- a/claude.md
+++ b/claude.md
@@ -18,18 +18,10 @@ A Windows desktop application for synchronized multi-room audio playback using t
## Architecture
-### Three-Tier Project Structure
+### Project Structure
```
src/
-├── Sendspin.SDK/ # Cross-platform protocol SDK (NuGet package)
-│ ├── Audio/ # Decoding, buffering, and pipeline orchestration
-│ ├── Client/ # Protocol client and host services
-│ ├── Connection/ # WebSocket transport layer
-│ ├── Discovery/ # mDNS service discovery and advertisement
-│ ├── Protocol/ # Message serialization and types
-│ └── Synchronization/ # Clock sync (Kalman filter)
-│
├── SendspinClient.Services/ # Windows-specific service implementations
│ ├── Audio/ # WASAPI player via NAudio
│ ├── Discord/ # Discord Rich Presence integration
@@ -41,17 +33,16 @@ src/
└── Views/ # XAML views
```
-> **Historical Note**: Prior to v2.0.0, the core protocol implementation was in `SendspinClient.Core`. This was renamed to `Sendspin.SDK` to support NuGet packaging and cross-platform use.
+### External Dependencies
+- **[Sendspin.SDK](https://www.nuget.org/packages/Sendspin.SDK)** (NuGet package) — Cross-platform protocol SDK providing audio pipeline, clock sync, protocol messages, mDNS discovery, and codec decoding. Source lives at [Sendspin/sendspin-dotnet](https://github.com/Sendspin/sendspin-dotnet).
### Dependency Flow
```
SendspinClient (WPF)
└─▶ SendspinClient.Services (Windows-specific)
- └─▶ Sendspin.SDK (Cross-platform)
+ └─▶ Sendspin.SDK (NuGet package)
```
-The SDK contains no Windows dependencies—it can be used to build players for other platforms.
-
---
## Quick Start
@@ -113,7 +104,7 @@ Both modes use the same protocol and can be used simultaneously.
The Kalman filter synchronizes local time with server time for sample-accurate multi-room sync.
-**Key file**: `src/Sendspin.SDK/Synchronization/KalmanClockSynchronizer.cs`
+**SDK class**: `KalmanClockSynchronizer` (in Sendspin.SDK — [source](https://github.com/Sendspin/sendspin-dotnet))
```
Server sends: server timestamp (monotonic, near 0)
@@ -142,7 +133,7 @@ The `IClockSynchronizer` interface provides:
The pipeline orchestrates audio from network to speakers:
-**Key file**: `src/Sendspin.SDK/Audio/AudioPipeline.cs`
+**SDK class**: `AudioPipeline` (in Sendspin.SDK)
```
Network → Decoder → TimedAudioBuffer → SampleSource → WASAPI
@@ -160,7 +151,7 @@ Network → Decoder → TimedAudioBuffer → SampleSource → WASAPI
### TimedAudioBuffer & Sync Correction
-**Key file**: `src/Sendspin.SDK/Audio/TimedAudioBuffer.cs`
+**SDK class**: `TimedAudioBuffer` (in Sendspin.SDK)
The buffer handles:
1. Storing PCM samples with server timestamps
@@ -195,8 +186,7 @@ syncError = elapsedTime - samplesReadTime - outputLatency
**Sync Correction Constants** (default values):
```csharp
-EntryDeadbandMicroseconds = 2_000; // 2ms - start correcting when error exceeds this
-ExitDeadbandMicroseconds = 500; // 0.5ms - stop correcting when below (hysteresis)
+DeadbandMicroseconds = 2_000; // 2ms - start correcting when error exceeds this
ResamplingThresholdMicroseconds = 15_000; // 15ms - resampling vs drop/insert boundary
ReanchorThresholdMicroseconds = 500_000; // 500ms - clear buffer and restart
MaxSpeedCorrection = 0.02; // 2% max correction rate (Windows default)
@@ -228,7 +218,7 @@ var buffer = new TimedAudioBuffer(format, clockSync, capacityMs, options, logger
### Clock Sync Gating
-**Key file**: `src/Sendspin.SDK/Audio/AudioPipeline.cs`
+**SDK class**: `AudioPipeline` (in Sendspin.SDK)
The pipeline waits for `IClockSynchronizer.IsConverged` before starting playback. This ensures the Kalman filter has enough measurements to provide accurate timestamp conversion.
@@ -252,7 +242,7 @@ Configuration via `appsettings.json`:
### High-Precision Timer
-**Key file**: `src/Sendspin.SDK/Synchronization/HighPrecisionTimer.cs`
+**SDK class**: `HighPrecisionTimer` (in Sendspin.SDK)
Windows `DateTime` only has ~15ms resolution. For microsecond-accurate sync, we use `Stopwatch.GetTimestamp()` which uses hardware performance counters (~100ns resolution).
@@ -561,54 +551,11 @@ Enable verbose logging in appsettings.json:
---
-## NuGet Package: Sendspin.SDK
-
-> **Important**: The `src/Sendspin.SDK/` project in this repository is the source for the [Sendspin.SDK NuGet package](https://www.nuget.org/packages/Sendspin.SDK). Changes to this project affect external consumers who depend on the NuGet package.
-
-### When to Publish to NuGet
-
-Publish a new version when SDK changes include:
-- **Bug fixes** that affect SDK consumers (bump patch: 2.1.0 → 2.1.1)
-- **New features** like new protocol messages, events, or public APIs (bump minor: 2.1.0 → 2.2.0)
-- **Breaking changes** to interfaces or behavior (bump major: 2.x → 3.0.0)
-
-Do NOT publish for:
-- Changes only to `SendspinClient` or `SendspinClient.Services` (Windows app only)
-- Internal refactoring that doesn't change public API
-- Documentation-only changes
-
-### Publishing Checklist
-
-1. **Update version** in `src/Sendspin.SDK/Sendspin.SDK.csproj`:
- ```xml
- X.Y.Z
- ```
-
-2. **Update release notes** in the same file:
- ```xml
-
- vX.Y.Z:
- - Description of changes
- ...
-
- ```
-
-3. **Build and pack**:
- ```bash
- dotnet pack src/Sendspin.SDK/Sendspin.SDK.csproj -c Release
- ```
-
-4. **Publish to NuGet**:
- ```bash
- dotnet nuget push src/Sendspin.SDK/bin/Release/Sendspin.SDK.X.Y.Z.nupkg --api-key YOUR_KEY --source https://api.nuget.org/v3/index.json
- ```
+## Sendspin.SDK (External Dependency)
-### Semantic Versioning
+The SDK is consumed as a NuGet package. Source and publishing are managed in [Sendspin/sendspin-dotnet](https://github.com/Sendspin/sendspin-dotnet).
-Follow [SemVer](https://semver.org/):
-- **MAJOR** (3.0.0): Breaking changes to public interfaces
-- **MINOR** (2.1.0): New features, backward compatible
-- **PATCH** (2.0.1): Bug fixes, backward compatible
+To update the SDK version, change the `Version` in the `` entries in both `SendspinClient.csproj` and `SendspinClient.Services.csproj`.
---
@@ -663,18 +610,18 @@ Follow [SemVer](https://semver.org/):
|------|---------|
| `src/SendspinClient/App.xaml.cs` | DI setup, startup, shutdown |
| `src/SendspinClient/ViewModels/MainViewModel.cs` | Primary UI state and commands |
-| `src/Sendspin.SDK/Audio/AudioPipeline.cs` | Audio flow orchestration |
-| `src/Sendspin.SDK/Audio/TimedAudioBuffer.cs` | Sync-aware sample buffer |
-| `src/Sendspin.SDK/Synchronization/KalmanClockSynchronizer.cs` | Clock sync algorithm |
-| `src/Sendspin.SDK/Synchronization/HighPrecisionTimer.cs` | Microsecond-precision timing |
-| `src/Sendspin.SDK/Client/SendSpinHostService.cs` | Server-initiated connection mode |
-| `src/Sendspin.SDK/Client/SendSpinClient.cs` | Client-initiated connection mode |
| `src/SendspinClient.Services/Audio/WasapiAudioPlayer.cs` | Windows audio output |
| `src/SendspinClient.Services/Audio/DynamicResamplerSampleProvider.cs` | Playback rate resampling for sync |
-| `src/Sendspin.SDK/Protocol/Messages/MessageTypes.cs` | Protocol message definitions |
-| `src/Sendspin.SDK/Protocol/Optional.cs` | Optional for JSON absent vs null distinction |
-| `src/Sendspin.SDK/Protocol/OptionalJsonConverter.cs` | JSON converter for Optional |
-| `src/Sendspin.SDK/Audio/SyncCorrectionOptions.cs` | Configurable sync correction parameters |
+| `src/SendspinClient.Services/Audio/BufferedAudioSampleSource.cs` | Bridges SDK buffer to NAudio |
+
+SDK classes (in NuGet package — source at [sendspin-dotnet](https://github.com/Sendspin/sendspin-dotnet)):
+- `AudioPipeline` — Audio flow orchestration
+- `TimedAudioBuffer` — Sync-aware sample buffer
+- `KalmanClockSynchronizer` — Clock sync algorithm
+- `HighPrecisionTimer` — Microsecond-precision timing
+- `SendSpinHostService` / `SendSpinClient` — Connection modes
+- `SyncCorrectionOptions` — Configurable sync correction parameters
+- `Optional` — JSON absent vs null distinction
---
diff --git a/src/Sendspin.SDK/Audio/AudioDecoderFactory.cs b/src/Sendspin.SDK/Audio/AudioDecoderFactory.cs
deleted file mode 100644
index 8502810..0000000
--- a/src/Sendspin.SDK/Audio/AudioDecoderFactory.cs
+++ /dev/null
@@ -1,55 +0,0 @@
-//
-// Licensed under the MIT License. See LICENSE file in the project root.
-//
-
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Logging.Abstractions;
-using Sendspin.SDK.Audio.Codecs;
-using Sendspin.SDK.Models;
-
-namespace Sendspin.SDK.Audio;
-
-///
-/// Factory for creating audio decoders based on the codec in the audio format.
-///
-public sealed class AudioDecoderFactory : IAudioDecoderFactory
-{
- private readonly ILoggerFactory _loggerFactory;
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// Optional logger factory for decoder diagnostics.
- public AudioDecoderFactory(ILoggerFactory? loggerFactory = null)
- {
- _loggerFactory = loggerFactory ?? NullLoggerFactory.Instance;
- }
-
- ///
- public IAudioDecoder Create(AudioFormat format)
- {
- ArgumentNullException.ThrowIfNull(format);
-
- return format.Codec.ToLowerInvariant() switch
- {
- AudioCodecs.Opus => new OpusDecoder(format),
- AudioCodecs.Pcm => new PcmDecoder(format),
- AudioCodecs.Flac => new FlacDecoder(format, _loggerFactory.CreateLogger()),
- _ => throw new NotSupportedException($"Unsupported audio codec: {format.Codec}"),
- };
- }
-
- ///
- public bool IsSupported(string codec)
- {
- ArgumentNullException.ThrowIfNull(codec);
-
- return codec.ToLowerInvariant() switch
- {
- AudioCodecs.Opus => true,
- AudioCodecs.Pcm => true,
- AudioCodecs.Flac => true,
- _ => false,
- };
- }
-}
diff --git a/src/Sendspin.SDK/Audio/AudioPipeline.cs b/src/Sendspin.SDK/Audio/AudioPipeline.cs
deleted file mode 100644
index 698ed2d..0000000
--- a/src/Sendspin.SDK/Audio/AudioPipeline.cs
+++ /dev/null
@@ -1,745 +0,0 @@
-//
-// Licensed under the MIT License. See LICENSE file in the project root.
-//
-
-using System.Diagnostics;
-using Microsoft.Extensions.Logging;
-using Sendspin.SDK.Models;
-using Sendspin.SDK.Protocol;
-using Sendspin.SDK.Synchronization;
-
-namespace Sendspin.SDK.Audio;
-
-///
-/// Orchestrates the complete audio pipeline from incoming chunks to output.
-/// Manages decoder, buffer, and player lifecycle and coordinates their interaction.
-///
-///
-///
-/// The pipeline operates in the following states:
-/// - Idle: No active stream
-/// - Starting: Initializing components for a new stream
-/// - Buffering: Accumulating audio before playback starts
-/// - Playing: Actively playing audio
-/// - Stopping: Shutting down the current stream
-/// - Error: An error occurred
-///
-///
-/// Audio flow:
-/// 1. ProcessAudioChunk receives encoded audio with server timestamp
-/// 2. Decoder converts to float PCM samples
-/// 3. TimedAudioBuffer stores samples with playback timestamps
-/// 4. NAudio reads from buffer when samples are due for playback
-///
-///
-public sealed class AudioPipeline : IAudioPipeline
-{
- private readonly ILogger _logger;
- private readonly IAudioDecoderFactory _decoderFactory;
- private readonly IClockSynchronizer _clockSync;
- private readonly Func _bufferFactory;
- private readonly Func _playerFactory;
- private readonly Func, IAudioSampleSource> _sourceFactory;
- private readonly IHighPrecisionTimer _precisionTimer;
-
- private IAudioDecoder? _decoder;
- private ITimedAudioBuffer? _buffer;
- private IAudioPlayer? _player;
- private IAudioSampleSource? _sampleSource;
-
- private float[] _decodeBuffer = Array.Empty();
- private AudioFormat? _currentFormat;
- private int _volume = 100;
- private bool _muted;
- private long _lastSyncLogTime;
- private bool _usingAudioClock;
- private bool? _lastAudioClockAvailable; // For tracking timing source transitions
-
- // How often to log sync status during playback (microseconds)
- private const long SyncLogIntervalMicroseconds = 5_000_000; // 5 seconds
-
- // Clock sync wait configuration
- private readonly bool _waitForConvergence;
- private readonly int _convergenceTimeoutMs;
- private long _bufferReadyTime;
- private bool _loggedSyncWaiting;
-
- ///
- public AudioPipelineState State { get; private set; } = AudioPipelineState.Idle;
-
- ///
- public bool IsReady => _decoder != null && _buffer != null;
-
- ///
- public AudioBufferStats? BufferStats => _buffer?.GetStats();
-
- ///
- public AudioFormat? CurrentFormat => _currentFormat;
-
- ///
- public AudioFormat? OutputFormat => _player?.OutputFormat;
-
- ///
- public int DetectedOutputLatencyMs => _player?.OutputLatencyMs ?? 0;
-
- ///
- public event EventHandler? StateChanged;
-
- ///
- public event EventHandler? ErrorOccurred;
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// Logger for diagnostics.
- /// Factory for creating audio decoders.
- /// Clock synchronizer for timestamp conversion.
- /// Factory for creating timed audio buffers.
- /// Factory for creating audio players.
- /// Factory for creating sample sources.
- /// High-precision timer for accurate timing (optional, uses shared instance if null).
- /// Whether to wait for clock sync convergence before starting playback (default: true).
- /// Timeout in milliseconds to wait for clock sync convergence (default: 5000ms).
- /// Whether to wrap the timer with monotonicity enforcement for VM resilience (default: true).
- public AudioPipeline(
- ILogger logger,
- IAudioDecoderFactory decoderFactory,
- IClockSynchronizer clockSync,
- Func bufferFactory,
- Func playerFactory,
- Func, IAudioSampleSource> sourceFactory,
- IHighPrecisionTimer? precisionTimer = null,
- bool waitForConvergence = true,
- int convergenceTimeoutMs = 5000,
- bool useMonotonicTimer = true)
- {
- _logger = logger;
- _decoderFactory = decoderFactory;
- _clockSync = clockSync;
- _bufferFactory = bufferFactory;
- _playerFactory = playerFactory;
- _sourceFactory = sourceFactory;
- _waitForConvergence = waitForConvergence;
- _convergenceTimeoutMs = convergenceTimeoutMs;
-
- // Set up the precision timer, optionally wrapping with monotonic filter for VM resilience
- // Note: If player provides audio clock, that will be used instead (determined at playback start)
- var baseTimer = precisionTimer ?? HighPrecisionTimer.Shared;
- if (useMonotonicTimer)
- {
- _precisionTimer = new MonotonicTimer(baseTimer, logger);
- _logger.LogDebug("MonotonicTimer wrapper enabled (will be used as fallback if audio clock unavailable)");
- }
- else
- {
- _precisionTimer = baseTimer;
- }
-
- // Log timer precision at startup
- if (HighPrecisionTimer.IsHighResolution)
- {
- _logger.LogDebug(
- "Using high-precision timer with {Resolution:F2}ns resolution",
- HighPrecisionTimer.GetResolutionNanoseconds());
- }
- else
- {
- _logger.LogWarning("High-resolution timing not available, sync accuracy may be reduced");
- }
- }
-
- ///
- public async Task StartAsync(AudioFormat format, long? targetTimestamp = null, CancellationToken cancellationToken = default)
- {
- // Stop any existing stream first
- if (State != AudioPipelineState.Idle && State != AudioPipelineState.Error)
- {
- await StopAsync();
- }
-
- SetState(AudioPipelineState.Starting);
-
- // Reset MonotonicTimer state to avoid stale timing from previous session
- // Without this, a 30s pause causes MonotonicTimer to be 30s behind real time
- // (forward jump clamping eats the gap), resulting in a 30s delay on resume
- if (_precisionTimer is MonotonicTimer mt)
- {
- mt.Reset();
- }
-
- try
- {
- _currentFormat = format;
-
- // Create decoder for the stream format
- _decoder = _decoderFactory.Create(format);
- _decodeBuffer = new float[_decoder.MaxSamplesPerFrame];
-
- _logger.LogDebug(
- "Decoder created for {Codec}, max frame size: {MaxSamples} samples",
- format.Codec,
- _decoder.MaxSamplesPerFrame);
-
- // Create timed buffer
- _buffer = _bufferFactory(format, _clockSync);
-
- // Subscribe to buffer reanchor event (if buffer supports it)
- if (_buffer is TimedAudioBuffer timedBuffer)
- {
- timedBuffer.ReanchorRequired += OnReanchorRequired;
- }
-
- // Create audio player
- _player = _playerFactory();
- await _player.InitializeAsync(format, cancellationToken);
-
- // Set output latency for diagnostic/logging purposes
- _buffer.OutputLatencyMicroseconds = _player.OutputLatencyMs * 1000L;
-
- // Set calibrated startup latency for sync error compensation (push-model backends only)
- _buffer.CalibratedStartupLatencyMicroseconds = _player.CalibratedStartupLatencyMs * 1000L;
- if (_player.CalibratedStartupLatencyMs > 0)
- {
- _logger.LogInformation(
- "[Playback] Startup latency: {CalibratedMs}ms (output latency: {OutputMs}ms)",
- _player.CalibratedStartupLatencyMs,
- _player.OutputLatencyMs);
- }
- else
- {
- _logger.LogDebug(
- "[Playback] Output latency: {OutputMs}ms, No startup latency compensation",
- _player.OutputLatencyMs);
- }
-
- // Determine and log which timing source will be used for sync calculation
- _usingAudioClock = _player.GetAudioClockMicroseconds().HasValue;
- _lastAudioClockAvailable = _usingAudioClock; // Initialize for transition tracking
- if (_usingAudioClock)
- {
- _buffer.TimingSourceName = "audio-clock";
- _logger.LogInformation("[Timing] Using audio hardware clock for sync timing (VM-immune)");
- }
- else if (_precisionTimer is MonotonicTimer)
- {
- _buffer.TimingSourceName = "monotonic";
- _logger.LogInformation("[Timing] Using MonotonicTimer for sync timing (audio clock not available)");
- }
- else
- {
- _buffer.TimingSourceName = "wall-clock";
- _logger.LogInformation("[Timing] Using wall clock for sync timing (audio clock not available)");
- }
-
- // Create sample source bridging buffer to player
- _sampleSource = _sourceFactory(_buffer, GetCurrentLocalTimeMicroseconds);
- _player.SetSampleSource(_sampleSource);
-
- // Apply volume/mute settings
- _player.Volume = _volume / 100f;
- _player.IsMuted = _muted;
-
- // Subscribe to player events
- _player.StateChanged += OnPlayerStateChanged;
- _player.ErrorOccurred += OnPlayerError;
-
- SetState(AudioPipelineState.Buffering);
- _logger.LogInformation("[Playback] Audio pipeline started: {Format}", format);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to start audio pipeline");
- await CleanupAsync();
- SetState(AudioPipelineState.Error);
- ErrorOccurred?.Invoke(this, new AudioPipelineError("Failed to start pipeline", ex));
- throw;
- }
- }
-
- ///
- public async Task StopAsync()
- {
- if (State == AudioPipelineState.Idle)
- {
- return;
- }
-
- SetState(AudioPipelineState.Stopping);
-
- await CleanupAsync();
-
- SetState(AudioPipelineState.Idle);
- _logger.LogInformation("[Playback] Audio pipeline stopped");
- }
-
- ///
- public void NotifyReconnect()
- {
- _buffer?.NotifyReconnect();
- _player?.NotifyReconnect();
- _logger.LogInformation("[Correction] Pipeline notified of reconnect, stabilization period active");
- }
-
- ///
- public void Clear(long? newTargetTimestamp = null)
- {
- _buffer?.Clear();
- _decoder?.Reset();
-
- // Reset monotonic timer state to avoid carrying over stale time tracking
- // Only needed when MonotonicTimer is the active timing source (not when using audio clock)
- if (!_usingAudioClock && _precisionTimer is MonotonicTimer monotonicTimer)
- {
- monotonicTimer.Reset();
- _logger.LogDebug("Reset MonotonicTimer state on buffer clear");
- }
-
- // Reset sync wait state so we wait for convergence again after clear
- _bufferReadyTime = 0;
- _loggedSyncWaiting = false;
-
- if (State == AudioPipelineState.Playing)
- {
- SetState(AudioPipelineState.Buffering);
- }
-
- _logger.LogDebug("Audio buffer cleared");
- }
-
- ///
- public void ProcessAudioChunk(AudioChunk chunk)
- {
- if (_decoder == null || _buffer == null)
- {
- _logger.LogWarning("Received audio chunk but pipeline not started");
- return;
- }
-
- try
- {
- // Decode the audio frame
- var samplesDecoded = _decoder.Decode(chunk.EncodedData, _decodeBuffer);
-
- if (samplesDecoded > 0)
- {
- // Add decoded samples to buffer with server timestamp
- _buffer.Write(_decodeBuffer.AsSpan(0, samplesDecoded), chunk.ServerTimestamp);
-
- // Periodically log sync error during playback
- if (State == AudioPipelineState.Playing)
- {
- LogSyncStatusIfNeeded();
- }
-
- // Start playback when buffer is ready AND (optionally) clock is synced
- // JS client approach: wait for clock sync convergence to ensure accurate timing
- if (State == AudioPipelineState.Buffering && _buffer.IsReadyForPlayback)
- {
- if (ShouldWaitForClockSync())
- {
- LogSyncWaitingIfNeeded();
- }
- else
- {
- StartPlayback();
- }
- }
- }
- }
- catch (Exception ex)
- {
- // Log but don't crash - one bad frame shouldn't stop the stream
- _logger.LogWarning(ex, "Error processing audio chunk, skipping frame");
- }
- }
-
- ///
- public void SetVolume(int volume)
- {
- _volume = Math.Clamp(volume, 0, 100);
- if (_player != null)
- {
- _player.Volume = _volume / 100f;
- }
-
- _logger.LogDebug("Volume set to {Volume}%", _volume);
- }
-
- ///
- public void SetMuted(bool muted)
- {
- _muted = muted;
- if (_player != null)
- {
- _player.IsMuted = muted;
- }
-
- _logger.LogDebug("Mute set to {Muted}", muted);
- }
-
- ///
- public async Task SwitchDeviceAsync(string? deviceId, CancellationToken cancellationToken = default)
- {
- if (_player == null)
- {
- _logger.LogWarning("Cannot switch audio device - pipeline not started");
- return;
- }
-
- var wasPlaying = State == AudioPipelineState.Playing;
-
- _logger.LogInformation("Switching audio device, currently {State}", State);
-
- try
- {
- // Switch the audio device - this stops/restarts playback internally
- await _player.SwitchDeviceAsync(deviceId, cancellationToken);
-
- // Update the buffer's latency values for the new device
- // The new device may have different latency characteristics
- if (_buffer != null)
- {
- _buffer.OutputLatencyMicroseconds = _player.OutputLatencyMs * 1000L;
- _buffer.CalibratedStartupLatencyMicroseconds = _player.CalibratedStartupLatencyMs * 1000L;
- _logger.LogDebug(
- "Updated latencies after device switch: output={LatencyMs}ms, calibrated={CalibratedMs}ms",
- _player.OutputLatencyMs,
- _player.CalibratedStartupLatencyMs);
-
- // Trigger a soft re-anchor to reset sync error tracking
- // This prevents the timing discontinuity from causing false sync corrections
- if (_buffer is TimedAudioBuffer timedBuffer)
- {
- timedBuffer.ResetSyncTracking();
- _logger.LogDebug("Reset sync tracking after device switch");
- }
- }
-
- // If we were playing and the player resumed, ensure state is correct
- if (wasPlaying && _player.State == AudioPlayerState.Playing)
- {
- // Reset sync monitoring counters since timing has been reset
- _lastSyncLogTime = _precisionTimer.GetCurrentTimeMicroseconds();
-
- SetState(AudioPipelineState.Playing);
- }
- else if (wasPlaying)
- {
- // Player didn't resume automatically - might need buffering
- SetState(AudioPipelineState.Buffering);
- }
-
- _logger.LogInformation(
- "Audio device switched successfully, output latency: {LatencyMs}ms",
- _player.OutputLatencyMs);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to switch audio device");
- SetState(AudioPipelineState.Error);
- ErrorOccurred?.Invoke(this, new AudioPipelineError("Failed to switch audio device", ex));
- throw;
- }
- }
-
- ///
- public async ValueTask DisposeAsync()
- {
- await StopAsync();
- }
-
- ///
- /// Gets the current local time in microseconds, preferring audio hardware clock when available.
- /// Used by the sample source to know when to release audio.
- ///
- ///
- ///
- /// Priority: Audio hardware clock (if player provides it) → MonotonicTimer (wall clock fallback).
- ///
- ///
- /// Audio hardware clocks are immune to VM wall clock issues because they run on the
- /// audio device's crystal oscillator, not the hypervisor's timer.
- ///
- ///
- private long GetCurrentLocalTimeMicroseconds()
- {
- // Try audio hardware clock first (VM-immune)
- var audioClockTime = _player?.GetAudioClockMicroseconds();
- var audioClockAvailable = audioClockTime.HasValue;
-
- // Log timing source transitions (only after initial setup)
- if (_lastAudioClockAvailable.HasValue && audioClockAvailable != _lastAudioClockAvailable.Value)
- {
- var fromSource = _lastAudioClockAvailable.Value ? "audio-clock" : (_precisionTimer is MonotonicTimer ? "monotonic" : "wall-clock");
- var toSource = audioClockAvailable ? "audio-clock" : (_precisionTimer is MonotonicTimer ? "monotonic" : "wall-clock");
- _logger.LogInformation("[Timing] Source changed: {FromSource} → {ToSource}", fromSource, toSource);
-
- // Update buffer's timing source name
- if (_buffer != null)
- {
- _buffer.TimingSourceName = toSource;
- }
- }
-
- _lastAudioClockAvailable = audioClockAvailable;
-
- if (audioClockAvailable)
- {
- return audioClockTime!.Value;
- }
-
- // Fall back to MonotonicTimer (filtered wall clock)
- return _precisionTimer.GetCurrentTimeMicroseconds();
- }
-
- private void StartPlayback()
- {
- if (_player == null)
- {
- return;
- }
-
- try
- {
- var syncStatus = _clockSync.GetStatus();
- _player.Play();
-
- // Reset sync monitoring counter
- _lastSyncLogTime = _precisionTimer.GetCurrentTimeMicroseconds();
-
- SetState(AudioPipelineState.Playing);
- _logger.LogInformation(
- "[Playback] Starting playback: buffer={BufferMs:F0}ms, sync offset={OffsetMs:F2}ms (±{UncertaintyMs:F2}ms), " +
- "output latency={OutputLatencyMs}ms, timer resolution={ResolutionNs:F0}ns",
- _buffer?.BufferedMilliseconds ?? 0,
- syncStatus.OffsetMilliseconds,
- syncStatus.OffsetUncertaintyMicroseconds / 1000.0,
- DetectedOutputLatencyMs,
- HighPrecisionTimer.GetResolutionNanoseconds());
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to start playback");
- ErrorOccurred?.Invoke(this, new AudioPipelineError("Failed to start playback", ex));
- }
- }
-
- ///
- /// Determines whether we should wait for clock sync convergence before starting playback.
- ///
- /// True if we should wait, false if we can proceed with playback.
- private bool ShouldWaitForClockSync()
- {
- // If wait is disabled, always proceed
- if (!_waitForConvergence)
- {
- return false;
- }
-
- // If clock has minimal sync (2+ measurements), proceed
- // Full convergence happens in background, sync correction handles any estimation errors
- if (_clockSync.HasMinimalSync)
- {
- return false;
- }
-
- // Track when buffer first became ready (for timeout calculation)
- if (_bufferReadyTime == 0)
- {
- _bufferReadyTime = _precisionTimer.GetCurrentTimeMicroseconds();
- }
-
- // Check for timeout - proceed anyway if we've waited too long
- var elapsed = _precisionTimer.GetCurrentTimeMicroseconds() - _bufferReadyTime;
- if (elapsed > _convergenceTimeoutMs * 1000L)
- {
- var status = _clockSync.GetStatus();
- _logger.LogWarning(
- "[ClockSync] Timeout after {ElapsedMs}ms. Starting playback without full convergence. " +
- "Measurements: {Count}, Uncertainty: {Uncertainty:F2}ms",
- elapsed / 1000,
- status.MeasurementCount,
- status.OffsetUncertaintyMicroseconds / 1000.0);
- return false; // Timeout - proceed anyway
- }
-
- return true; // Still waiting for convergence
- }
-
- ///
- /// Logs that we're waiting for clock sync convergence (only once per wait period).
- ///
- private void LogSyncWaitingIfNeeded()
- {
- if (!_loggedSyncWaiting)
- {
- _loggedSyncWaiting = true;
- var status = _clockSync.GetStatus();
- _logger.LogInformation(
- "[ClockSync] Buffer ready ({BufferMs:F0}ms), waiting for convergence. " +
- "Measurements: {Count}, Uncertainty: {Uncertainty:F2}ms, Converged: {Converged}",
- _buffer?.BufferedMilliseconds ?? 0,
- status.MeasurementCount,
- status.OffsetUncertaintyMicroseconds / 1000.0,
- status.IsConverged);
- }
- }
-
- private async Task CleanupAsync()
- {
- // Unsubscribe from events
- if (_player != null)
- {
- _player.StateChanged -= OnPlayerStateChanged;
- _player.ErrorOccurred -= OnPlayerError;
- _player.Stop();
- await _player.DisposeAsync();
- _player = null;
- }
-
- // Unsubscribe from buffer events
- if (_buffer is TimedAudioBuffer timedBuffer)
- {
- timedBuffer.ReanchorRequired -= OnReanchorRequired;
- }
-
- _decoder?.Dispose();
- _decoder = null;
-
- _buffer?.Dispose();
- _buffer = null;
-
- _sampleSource = null;
- _decodeBuffer = Array.Empty();
- _currentFormat = null;
- }
-
- private void OnReanchorRequired(object? sender, EventArgs e)
- {
- var stats = _buffer?.GetStats();
- _logger.LogWarning(
- "[Correction] Re-anchor required: sync error {SyncErrorMs:F1}ms exceeds threshold. " +
- "Dropped={Dropped}, Inserted={Inserted}. Clearing buffer for resync.",
- stats?.SyncErrorMs ?? 0,
- stats?.SamplesDroppedForSync ?? 0,
- stats?.SamplesInsertedForSync ?? 0);
-
- // Clear and restart buffering
- Clear();
- }
-
- private void OnPlayerStateChanged(object? sender, AudioPlayerState state)
- {
- if (state == AudioPlayerState.Error)
- {
- SetState(AudioPipelineState.Error);
- }
- }
-
- private void OnPlayerError(object? sender, AudioPlayerError error)
- {
- _logger.LogError(error.Exception, "Player error: {Message}", error.Message);
- ErrorOccurred?.Invoke(this, new AudioPipelineError(error.Message, error.Exception));
- }
-
- private void SetState(AudioPipelineState newState)
- {
- if (State != newState)
- {
- _logger.LogDebug("Pipeline state: {OldState} -> {NewState}", State, newState);
- State = newState;
- StateChanged?.Invoke(this, newState);
- }
- }
-
- ///
- /// Logs sync status periodically during playback for monitoring drift.
- ///
- private void LogSyncStatusIfNeeded()
- {
- var currentTime = _precisionTimer.GetCurrentTimeMicroseconds();
-
- // Only log every SyncLogIntervalMicroseconds
- if (currentTime - _lastSyncLogTime < SyncLogIntervalMicroseconds)
- {
- return;
- }
-
- _lastSyncLogTime = currentTime;
- var stats = _buffer?.GetStats();
- var clockStatus = _clockSync.GetStatus();
-
- if (stats is { IsPlaybackActive: true })
- {
- var syncErrorMs = stats.SyncErrorMs;
- var absError = Math.Abs(syncErrorMs);
-
- // Format correction mode for logging
- var correctionInfo = stats.CurrentCorrectionMode switch
- {
- SyncCorrectionMode.Dropping => $"DROPPING (dropped={stats.SamplesDroppedForSync})",
- SyncCorrectionMode.Inserting => $"INSERTING (inserted={stats.SamplesInsertedForSync})",
- _ => "none",
- };
-
- // Calculate derived values for debugging
- var samplesReadTimeMs = stats.SamplesReadSinceStart * (1000.0 / (_currentFormat!.SampleRate * _currentFormat.Channels));
-
- // Get drift status for enhanced diagnostics
- var driftInfo = clockStatus.IsDriftReliable
- ? $"drift={clockStatus.DriftMicrosecondsPerSecond:+0.0;-0.0}μs/s"
- : "drift=pending";
-
- // Get timer info for diagnostics - only include MonotonicTimer stats when it's the active source
- string timerInfo;
- if (_usingAudioClock)
- {
- timerInfo = "audio-clock";
- }
- else if (_precisionTimer is MonotonicTimer mt)
- {
- timerInfo = $"monotonic: {mt.GetStatsSummary()}";
- }
- else
- {
- timerInfo = "wall-clock";
- }
-
- // Use appropriate log level based on sync error magnitude
- if (absError > 50) // > 50ms - significant drift
- {
- _logger.LogWarning(
- "[SyncError] Drift: error={SyncErrorMs:+0.00;-0.00}ms, elapsed={Elapsed:F0}ms, readTime={ReadTime:F0}ms, " +
- "latencyComp={Latency}ms, {DriftInfo}, correction={Correction}, buffer={BufferMs:F0}ms, timing=[{TimerInfo}]",
- syncErrorMs,
- stats.ElapsedSinceStartMs,
- samplesReadTimeMs,
- _buffer?.OutputLatencyMicroseconds / 1000 ?? 0,
- driftInfo,
- correctionInfo,
- stats.BufferedMs,
- timerInfo);
- }
- else if (absError > 10) // > 10ms - noticeable
- {
- _logger.LogInformation(
- "[SyncError] Status: error={SyncErrorMs:+0.00;-0.00}ms, elapsed={Elapsed:F0}ms, readTime={ReadTime:F0}ms, " +
- "{DriftInfo}, correction={Correction}, buffer={BufferMs:F0}ms",
- syncErrorMs,
- stats.ElapsedSinceStartMs,
- samplesReadTimeMs,
- driftInfo,
- correctionInfo,
- stats.BufferedMs);
- }
- else // < 10ms - good sync
- {
- _logger.LogDebug(
- "[SyncError] OK: error={SyncErrorMs:+0.00;-0.00}ms, {DriftInfo}, buffer={BufferMs:F0}ms",
- syncErrorMs,
- driftInfo,
- stats.BufferedMs);
- }
- }
- }
-}
diff --git a/src/Sendspin.SDK/Audio/Codecs/FlacDecoder.cs b/src/Sendspin.SDK/Audio/Codecs/FlacDecoder.cs
deleted file mode 100644
index 87df23f..0000000
--- a/src/Sendspin.SDK/Audio/Codecs/FlacDecoder.cs
+++ /dev/null
@@ -1,285 +0,0 @@
-//
-// Licensed under the MIT License. See LICENSE file in the project root.
-//
-
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Logging.Abstractions;
-using Sendspin.SDK.Audio.Codecs.ThirdParty;
-using Sendspin.SDK.Models;
-
-namespace Sendspin.SDK.Audio.Codecs;
-
-///
-/// FLAC audio decoder using SimpleFlac library.
-/// Decodes FLAC-encoded audio frames to interleaved float PCM samples.
-///
-///
-/// This decoder handles streaming FLAC data by synthesizing a minimal FLAC header
-/// for each frame. While this adds overhead, it provides robust frame-by-frame
-/// decoding suitable for real-time streaming applications.
-///
-public sealed class FlacDecoder : IAudioDecoder
-{
- ///
- /// STREAMINFO metadata block type.
- ///
- private const byte StreamInfoBlockType = 0x00;
-
- ///
- /// Flag indicating last metadata block.
- ///
- private const byte LastMetadataBlockFlag = 0x80;
-
- ///
- /// STREAMINFO block length (always 34 bytes).
- ///
- private const int StreamInfoLength = 34;
-
- ///
- /// Total header size: 4 (marker) + 4 (block header) + 34 (STREAMINFO).
- ///
- private const int HeaderSize = 42;
-
- private readonly ILogger _logger;
- private readonly byte[] _header;
- private float _sampleScaleFactor;
- private bool _scaleFactorCalibrated;
- private bool _disposed;
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// Audio format configuration.
- /// Optional logger for diagnostic output.
- /// Thrown when format is not FLAC.
- public FlacDecoder(AudioFormat format, ILogger? logger = null)
- {
- if (!string.Equals(format.Codec, AudioCodecs.Flac, StringComparison.OrdinalIgnoreCase))
- {
- throw new ArgumentException($"Expected FLAC format, got {format.Codec}", nameof(format));
- }
-
- _logger = logger ?? NullLogger.Instance;
- Format = format;
- var bitsPerSample = format.BitDepth ?? 16;
-
- // Calculate scale factor for converting to float [-1.0, 1.0]
- // Use 1L to avoid integer overflow at 32-bit (1 << 31 overflows int, but not long)
- _sampleScaleFactor = 1.0f / (1L << (bitsPerSample - 1));
-
- // FLAC typically uses 4096 sample blocks, but can go up to 65535
- // We use 8192 as a reasonable max for streaming
- const int maxBlockSize = 8192;
- MaxSamplesPerFrame = maxBlockSize * format.Channels;
-
- // Use server-provided codec_header (real STREAMINFO) when available,
- // fall back to synthetic header otherwise
- if (format.CodecHeader is { } codecHeaderBase64)
- {
- _header = Convert.FromBase64String(codecHeaderBase64);
- _logger.LogDebug("FLAC decoder using server codec_header ({Bytes} bytes, {BitDepth}-bit)",
- _header.Length, bitsPerSample);
- }
- else
- {
- _header = BuildSyntheticHeader(format, maxBlockSize);
- _logger.LogDebug("FLAC decoder using synthetic header ({BitDepth}-bit)", bitsPerSample);
- }
- }
-
- ///
- public AudioFormat Format { get; }
-
- ///
- public int MaxSamplesPerFrame { get; }
-
- ///
- public int Decode(ReadOnlySpan encodedData, Span decodedSamples)
- {
- ObjectDisposedException.ThrowIf(_disposed, this);
-
- if (encodedData.IsEmpty)
- {
- return 0;
- }
-
- // Create a stream containing header + FLAC frame data
- var streamData = new byte[_header.Length + encodedData.Length];
- _header.CopyTo(streamData, 0);
- encodedData.CopyTo(streamData.AsSpan(_header.Length));
-
- using var stream = new MemoryStream(streamData, writable: false);
-
- var options = new ThirdParty.FlacDecoder.Options
- {
- ConvertOutputToBytes = false, // We'll convert samples directly
- ValidateOutputHash = false, // Not applicable for streaming
- };
-
- try
- {
- using var flacDecoder = new ThirdParty.FlacDecoder(stream, options);
-
- // Calibrate scale factor from actual FLAC STREAMINFO bit depth.
- // The stream/start message may report a different bit depth (e.g., 32 from PyAV's s32
- // container) than the actual FLAC encoding (e.g., 24-bit precision).
- if (!_scaleFactorCalibrated)
- {
- var actualBits = flacDecoder.BitsPerSample;
- _sampleScaleFactor = 1.0f / (1L << (actualBits - 1));
- _scaleFactorCalibrated = true;
-
- if (actualBits != (Format.BitDepth ?? 16))
- {
- _logger.LogWarning(
- "FLAC actual bit depth ({ActualBits}) differs from stream/start ({ReportedBits}), using actual",
- actualBits, Format.BitDepth ?? 16);
- Format.BitDepth = actualBits;
- }
- }
-
- // Decode frame(s)
- int totalSamplesWritten = 0;
-
- while (flacDecoder.DecodeFrame())
- {
- var sampleCount = flacDecoder.BufferSampleCount;
- var channelCount = flacDecoder.ChannelCount;
- var samplesNeeded = sampleCount * channelCount;
-
- if (totalSamplesWritten + samplesNeeded > decodedSamples.Length)
- {
- break; // Output buffer full
- }
-
- // Convert from long[][] (per-channel) to interleaved float
- ConvertToInterleavedFloat(
- flacDecoder.BufferSamples,
- sampleCount,
- channelCount,
- decodedSamples.Slice(totalSamplesWritten));
-
- totalSamplesWritten += samplesNeeded;
- }
-
- return totalSamplesWritten;
- }
- catch (Exception ex) when (ex is InvalidDataException or NotSupportedException)
- {
- _logger.LogWarning(ex, "FLAC frame decode failed ({BitDepth}-bit, {DataLen} bytes encoded)",
- Format.BitDepth ?? 16, encodedData.Length);
- return 0;
- }
- }
-
- ///
- public void Reset()
- {
- // FLAC frames are self-contained, no state to reset
- }
-
- ///
- public void Dispose()
- {
- _disposed = true;
- }
-
- ///
- /// Builds a synthetic FLAC header (fLaC marker + STREAMINFO block).
- ///
- private static byte[] BuildSyntheticHeader(AudioFormat format, int maxBlockSize)
- {
- var header = new byte[HeaderSize];
- var offset = 0;
-
- // fLaC marker (big-endian)
- header[offset++] = 0x66; // 'f'
- header[offset++] = 0x4C; // 'L'
- header[offset++] = 0x61; // 'a'
- header[offset++] = 0x43; // 'C'
-
- // Metadata block header: last block flag | type (0 = STREAMINFO)
- header[offset++] = LastMetadataBlockFlag | StreamInfoBlockType;
-
- // Block length (24-bit big-endian)
- header[offset++] = 0;
- header[offset++] = 0;
- header[offset++] = StreamInfoLength;
-
- // STREAMINFO block (34 bytes):
- // - Minimum block size (16 bits)
- var minBlockSize = (ushort)16; // FLAC minimum
- header[offset++] = (byte)(minBlockSize >> 8);
- header[offset++] = (byte)minBlockSize;
-
- // - Maximum block size (16 bits)
- header[offset++] = (byte)(maxBlockSize >> 8);
- header[offset++] = (byte)maxBlockSize;
-
- // - Minimum frame size (24 bits) - 0 = unknown
- header[offset++] = 0;
- header[offset++] = 0;
- header[offset++] = 0;
-
- // - Maximum frame size (24 bits) - 0 = unknown
- header[offset++] = 0;
- header[offset++] = 0;
- header[offset++] = 0;
-
- // Next 8 bytes encode: sample rate (20 bits), channels-1 (3 bits),
- // bits per sample-1 (5 bits), total samples (36 bits)
- var sampleRate = format.SampleRate;
- var channels = format.Channels;
- var bitsPerSample = format.BitDepth ?? 16;
-
- // Byte 0: sample rate bits 19-12
- header[offset++] = (byte)(sampleRate >> 12);
-
- // Byte 1: sample rate bits 11-4
- header[offset++] = (byte)(sampleRate >> 4);
-
- // Byte 2: sample rate bits 3-0 (4 bits) | channels-1 (3 bits) | bps-1 bit 4
- header[offset++] = (byte)(
- ((sampleRate & 0x0F) << 4) |
- ((channels - 1) << 1) |
- ((bitsPerSample - 1) >> 4));
-
- // Byte 3: bps-1 bits 3-0 (4 bits) | total samples bits 35-32 (4 bits)
- header[offset++] = (byte)(((bitsPerSample - 1) & 0x0F) << 4);
-
- // Bytes 4-7: total samples bits 31-0 (we use 0 = unknown for streaming)
- header[offset++] = 0;
- header[offset++] = 0;
- header[offset++] = 0;
- header[offset++] = 0;
-
- // MD5 signature (16 bytes) - all zeros for streaming
- for (int i = 0; i < 16; i++)
- {
- header[offset++] = 0;
- }
-
- return header;
- }
-
- ///
- /// Converts SimpleFlac's per-channel long samples to interleaved floats.
- ///
- private void ConvertToInterleavedFloat(
- long[][] channelSamples,
- int sampleCount,
- int channelCount,
- Span output)
- {
- var outputIndex = 0;
-
- for (int i = 0; i < sampleCount; i++)
- {
- for (int ch = 0; ch < channelCount; ch++)
- {
- // Convert to normalized float [-1.0, 1.0]
- output[outputIndex++] = channelSamples[ch][i] * _sampleScaleFactor;
- }
- }
- }
-}
diff --git a/src/Sendspin.SDK/Audio/Codecs/OpusDecoder.cs b/src/Sendspin.SDK/Audio/Codecs/OpusDecoder.cs
deleted file mode 100644
index 4141d65..0000000
--- a/src/Sendspin.SDK/Audio/Codecs/OpusDecoder.cs
+++ /dev/null
@@ -1,88 +0,0 @@
-//
-// Licensed under the MIT License. See LICENSE file in the project root.
-//
-
-using Concentus;
-using Sendspin.SDK.Models;
-
-namespace Sendspin.SDK.Audio.Codecs;
-
-///
-/// Opus audio decoder using Concentus library.
-/// Decodes Opus-encoded audio frames to interleaved float PCM samples.
-///
-public sealed class OpusDecoder : IAudioDecoder
-{
- private readonly IOpusDecoder _decoder;
- private readonly short[] _shortBuffer;
- private bool _disposed;
-
- ///
- public AudioFormat Format { get; }
-
- ///
- public int MaxSamplesPerFrame { get; }
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// Audio format configuration.
- /// Thrown when format is not Opus.
- public OpusDecoder(AudioFormat format)
- {
- if (!string.Equals(format.Codec, AudioCodecs.Opus, StringComparison.OrdinalIgnoreCase))
- {
- throw new ArgumentException($"Expected Opus format, got {format.Codec}", nameof(format));
- }
-
- Format = format;
-
- // Create Concentus decoder using the factory (preferred over deprecated constructor)
- _decoder = OpusCodecFactory.CreateDecoder(format.SampleRate, format.Channels);
-
- // Opus max frame is 120ms, but typically 20ms (960 samples at 48kHz per channel)
- // Allocate for worst case: 120ms * sampleRate / 1000 * channels
- MaxSamplesPerFrame = (format.SampleRate / 1000 * 120) * format.Channels;
- _shortBuffer = new short[MaxSamplesPerFrame];
- }
-
- ///
- public int Decode(ReadOnlySpan encodedData, Span decodedSamples)
- {
- ObjectDisposedException.ThrowIf(_disposed, this);
-
- // Decode to short buffer using Span overload (Concentus outputs interleaved shorts)
- // frameSize is samples per channel
- var maxFrameSize = MaxSamplesPerFrame / Format.Channels;
- var samplesPerChannel = _decoder.Decode(encodedData, _shortBuffer.AsSpan(), maxFrameSize);
-
- var totalSamples = samplesPerChannel * Format.Channels;
-
- // Convert shorts to normalized floats [-1.0, 1.0]
- for (int i = 0; i < totalSamples && i < decodedSamples.Length; i++)
- {
- decodedSamples[i] = _shortBuffer[i] / 32768f;
- }
-
- return Math.Min(totalSamples, decodedSamples.Length);
- }
-
- ///
- public void Reset()
- {
- ObjectDisposedException.ThrowIf(_disposed, this);
- _decoder.ResetState();
- }
-
- ///
- public void Dispose()
- {
- if (_disposed)
- {
- return;
- }
-
- _disposed = true;
- // Concentus decoder doesn't implement IDisposable
- }
-}
diff --git a/src/Sendspin.SDK/Audio/Codecs/PcmDecoder.cs b/src/Sendspin.SDK/Audio/Codecs/PcmDecoder.cs
deleted file mode 100644
index 87b1713..0000000
--- a/src/Sendspin.SDK/Audio/Codecs/PcmDecoder.cs
+++ /dev/null
@@ -1,99 +0,0 @@
-//
-// Licensed under the MIT License. See LICENSE file in the project root.
-//
-
-using System.Buffers.Binary;
-using Sendspin.SDK.Models;
-
-namespace Sendspin.SDK.Audio.Codecs;
-
-///
-/// PCM "decoder" - converts raw PCM bytes to normalized float samples.
-/// Supports 16-bit, 24-bit, and 32-bit integer PCM formats.
-///
-public sealed class PcmDecoder : IAudioDecoder
-{
- private readonly int _bytesPerSample;
- private bool _disposed;
-
- ///
- public AudioFormat Format { get; }
-
- ///
- public int MaxSamplesPerFrame { get; }
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// Audio format configuration.
- /// Thrown when format is not PCM.
- public PcmDecoder(AudioFormat format)
- {
- if (!string.Equals(format.Codec, AudioCodecs.Pcm, StringComparison.OrdinalIgnoreCase))
- {
- throw new ArgumentException($"Expected PCM format, got {format.Codec}", nameof(format));
- }
-
- Format = format;
- _bytesPerSample = (format.BitDepth ?? 16) / 8;
-
- // Assume max 50ms frames for PCM at any sample rate
- MaxSamplesPerFrame = (format.SampleRate / 20) * format.Channels;
- }
-
- ///
- public int Decode(ReadOnlySpan encodedData, Span decodedSamples)
- {
- ObjectDisposedException.ThrowIf(_disposed, this);
-
- var sampleCount = encodedData.Length / _bytesPerSample;
- var outputCount = Math.Min(sampleCount, decodedSamples.Length);
-
- var bitDepth = Format.BitDepth ?? 16;
-
- for (int i = 0; i < outputCount; i++)
- {
- var offset = i * _bytesPerSample;
- var slice = encodedData.Slice(offset, _bytesPerSample);
-
- decodedSamples[i] = bitDepth switch
- {
- 16 => BinaryPrimitives.ReadInt16LittleEndian(slice) / 32768f,
- 24 => Read24BitSample(slice) / 8388608f,
- 32 => BinaryPrimitives.ReadInt32LittleEndian(slice) / 2147483648f,
- _ => throw new NotSupportedException($"Unsupported bit depth: {bitDepth}"),
- };
- }
-
- return outputCount;
- }
-
- ///
- public void Reset()
- {
- // PCM is stateless, nothing to reset
- }
-
- ///
- public void Dispose()
- {
- _disposed = true;
- }
-
- ///
- /// Reads a 24-bit signed integer sample from 3 bytes (little-endian).
- ///
- private static int Read24BitSample(ReadOnlySpan data)
- {
- // Read 24-bit as signed integer (little-endian)
- int value = data[0] | (data[1] << 8) | (data[2] << 16);
-
- // Sign extend from 24-bit to 32-bit
- if ((value & 0x800000) != 0)
- {
- value |= unchecked((int)0xFF000000);
- }
-
- return value;
- }
-}
diff --git a/src/Sendspin.SDK/Audio/Codecs/ThirdParty/SimpleFlac.cs b/src/Sendspin.SDK/Audio/Codecs/ThirdParty/SimpleFlac.cs
deleted file mode 100644
index f7ea8d2..0000000
--- a/src/Sendspin.SDK/Audio/Codecs/ThirdParty/SimpleFlac.cs
+++ /dev/null
@@ -1,596 +0,0 @@
-// License: MIT
-//
-// Copyright (c) J.D. Purcell (C# port and enhancements)
-// Copyright (c) Project Nayuki (Simple FLAC decoder in Java)
-// https://www.nayuki.io/page/simple-flac-implementation
-//
-// Permission is hereby granted, free of charge, to any person obtaining a copy of
-// this software and associated documentation files (the "Software"), to deal in
-// the Software without restriction, including without limitation the rights to
-// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
-// of the Software, and to permit persons to whom the Software is furnished to do
-// so, subject to the following conditions:
-//
-// The above copyright notice and this permission notice shall be included in all
-// copies or substantial portions of the Software.
-//
-// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-// SOFTWARE.
-//
-// Source: https://github.com/jdpurcell/SimpleFlac
-// Vendored for SendspinClient on 2024-12-22
-
-using System;
-using System.Buffers.Binary;
-using System.IO;
-using System.Numerics;
-using System.Security.Cryptography;
-using System.Threading.Tasks;
-
-#nullable enable
-
-#pragma warning disable SA1101 // Prefix local calls with this
-#pragma warning disable SA1116 // Split parameters should start on line after declaration
-#pragma warning disable SA1117 // Parameters should be on same line or separate lines
-#pragma warning disable SA1119 // Statement should not use unnecessary parenthesis
-#pragma warning disable SA1201 // Elements should appear in the correct order
-#pragma warning disable SA1202 // Elements should be ordered by access
-#pragma warning disable SA1203 // Constants should appear before fields
-#pragma warning disable SA1204 // Static elements should appear before instance elements
-#pragma warning disable SA1306 // Field names should begin with lower-case letter
-#pragma warning disable SA1310 // Field names should not contain underscore
-#pragma warning disable SA1311 // Static readonly fields should begin with upper-case letter
-#pragma warning disable SA1400 // Access modifier should be declared
-#pragma warning disable SA1401 // Fields should be private
-#pragma warning disable SA1413 // Use trailing comma in multi-line initializers
-#pragma warning disable SA1500 // Braces for multi-line statements should not share line
-#pragma warning disable SA1501 // Statement should not be on a single line
-#pragma warning disable SA1503 // Braces should not be omitted
-#pragma warning disable SA1513 // Closing brace should be followed by blank line
-#pragma warning disable SA1516 // Elements should be separated by blank line
-#pragma warning disable SA1600 // Elements should be documented
-#pragma warning disable SA1601 // Partial elements should be documented
-#pragma warning disable SA1602 // Enumeration items should be documented
-#pragma warning disable SA1611 // Element parameters should be documented
-#pragma warning disable SA1615 // Element return value should be documented
-#pragma warning disable SA1623 // Property summary documentation should match accessors
-#pragma warning disable SA1642 // Constructor summary documentation should begin with standard text
-#pragma warning disable IDE0007 // Use implicit type
-#pragma warning disable IDE0046 // Convert to conditional expression
-#pragma warning disable IDE0047 // Remove unnecessary parentheses
-#pragma warning disable IDE0055 // Fix formatting
-#pragma warning disable IDE0057 // Use range operator
-#pragma warning disable IDE0090 // Use 'new(...)'
-#pragma warning disable IDE0161 // Convert to file-scoped namespace
-#pragma warning disable CA1002 // Do not expose generic lists
-#pragma warning disable CA1051 // Do not declare visible instance fields
-#pragma warning disable CA1305 // Specify IFormatProvider
-#pragma warning disable CA1502 // Avoid excessive complexity
-#pragma warning disable CA1505 // Avoid unmaintainable code
-#pragma warning disable CA1707 // Identifiers should not contain underscores
-#pragma warning disable CA1715 // Identifiers should have correct prefix
-#pragma warning disable CA1720 // Identifier contains type name
-#pragma warning disable CA1822 // Mark members as static
-#pragma warning disable CA1825 // Avoid zero-length array allocations
-#pragma warning disable CA1859 // Use concrete types when possible for improved performance
-#pragma warning disable CA2211 // Non-constant fields should not be visible
-#pragma warning disable SA1010 // Opening square brackets should not be preceded by a space
-#pragma warning disable SA1011 // Closing square bracket should not be preceded by a space
-#pragma warning disable SA1025 // Code should not contain multiple whitespace characters in a row
-#pragma warning disable SA1027 // Tabs and spaces should be used correctly
-#pragma warning disable SA1515 // Single-line comment should be preceded by blank line
-#pragma warning disable S3260 // Private classes should be sealed
-
-namespace Sendspin.SDK.Audio.Codecs.ThirdParty;
-
-public class FlacDecoder : IDisposable {
- private readonly Options _options;
- private readonly BitReader _reader;
- private readonly IncrementalHash? _outputHasher;
- private Task _outputHasherTask = Task.CompletedTask;
- private byte[] _expectedOutputHash = new byte[16];
-
- public long? StreamSampleCount { get; private set; }
- public int SampleRate { get; private set; }
- public int ChannelCount { get; private set; }
- public int BitsPerSample { get; private set; }
- public int BytesPerSample { get; private set; }
- public int MaxSamplesPerFrame { get; private set; }
-
- public long[][] BufferSamples { get; private set; } = [[]];
- public byte[] BufferBytes { get; private set; } = [];
- public int BufferSampleCount { get; private set; }
- public int BufferByteCount { get; private set; }
- public long RunningSampleCount { get; private set; }
-
- public int BlockAlign => BytesPerSample * ChannelCount;
-
- public FlacDecoder(Stream input, Options? options = null) {
- _options = options ?? new Options();
- _reader = new BitReader(input);
- try {
- ValidateOptions();
- ReadMetadata();
- }
- catch {
- _reader.Dispose();
- throw;
- }
- _outputHasher = _options.ValidateOutputHash ? IncrementalHash.CreateHash(HashAlgorithmName.MD5) : null;
- }
-
- public FlacDecoder(string path, Options? options = null)
- : this(new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 65536, FileOptions.SequentialScan), options)
- {
- }
-
- public void Dispose() {
- _reader.Dispose();
- if (_options.ValidateOutputHash) {
- _outputHasherTask.Wait();
- _outputHasher!.Dispose();
- }
- }
-
- private void ValidateOptions() {
- if (_options.ValidateOutputHash && !_options.ConvertOutputToBytes)
- throw new ArgumentException("Output hash validation requires conversion to bytes.");
- }
-
- private void ReadMetadata() {
- if (_reader.Read(32) != 0x664C6143)
- throw new InvalidDataException("FLAC stream marker not found.");
-
- bool foundLastMetadataBlock;
- do {
- foundLastMetadataBlock = _reader.Read(1) != 0;
- int type = (int)_reader.Read(7);
- int length = (int)_reader.Read(24);
- if (type == 0) {
- ReadStreaminfoBlock();
- }
- else {
- // Skip other blocks
- for (int i = 0; i < length; i++) {
- _reader.Skip(8);
- }
- }
- }
- while (!foundLastMetadataBlock);
-
- if (BufferSamples is null)
- throw new InvalidDataException("Stream info metadata block not found.");
- }
-
- private void ReadStreaminfoBlock() {
- _reader.Skip(16); // Minimum block size (samples)
- MaxSamplesPerFrame = (int)_reader.Read(16);
- _reader.Skip(24); // Minimum frame size (bytes)
- _reader.Skip(24); // Maximum frame size (bytes)
- SampleRate = (int)_reader.Read(20);
- ChannelCount = (int)_reader.Read(3) + 1;
- BitsPerSample = (int)_reader.Read(5) + 1;
- long streamSampleCount = (long)_reader.Read(36);
- for (int i = 0; i < 16; i++) {
- _expectedOutputHash[i] = (byte)_reader.Read(8);
- }
-
- StreamSampleCount = streamSampleCount != 0 ? streamSampleCount : null;
- BytesPerSample = (BitsPerSample + 7) / 8;
- BufferSamples = new long[ChannelCount][];
- for (int ch = 0; ch < ChannelCount; ch++) {
- BufferSamples[ch] = new long[MaxSamplesPerFrame];
- }
- if (_options.ConvertOutputToBytes) {
- BufferBytes = new byte[MaxSamplesPerFrame * BlockAlign];
- }
- }
-
- public bool DecodeFrame() {
- if (_reader.HasReachedEnd) {
- if (StreamSampleCount is not null && RunningSampleCount != StreamSampleCount) {
- throw new InvalidDataException("Stream sample count is incorrect.");
- }
-
- if (_options.ValidateOutputHash) {
- Span actualHash = stackalloc byte[16];
- _outputHasherTask.Wait();
- _outputHasher!.GetCurrentHash(actualHash);
-
- if (!actualHash.SequenceEqual(_expectedOutputHash))
- throw new InvalidDataException("Output hash is incorrect.");
- }
-
- return false;
- }
-
- if (_reader.Read(15) != 0x7FFC)
- throw new InvalidDataException("Invalid frame sync code.");
-
- _reader.Skip(1); // Variable block size flag
- int blockSizeCode = (int)_reader.Read(4);
- int sampleRateCode = (int)_reader.Read(4);
- int channelLayout = (int)_reader.Read(4);
- int bitDepthCode = (int)_reader.Read(3);
- _reader.Skip(1); // Reserved bit
-
- // Coded number (sample or frame number)
- int codedNumberLeadingOnes = BitOperations.LeadingZeroCount(~(_reader.Read(8) << 56));
- for (int i = 1; i < codedNumberLeadingOnes; i++) {
- _reader.Skip(8);
- }
-
- int frameSampleCount = blockSizeCode switch {
- 1 => 192,
- >= 2 and <= 5 => 576 << (blockSizeCode - 2),
- 6 => (int)_reader.Read(8) + 1,
- 7 => (int)_reader.Read(16) + 1,
- >= 8 and <= 15 => 256 << (blockSizeCode - 8),
- _ => throw new InvalidDataException("Reserved block size.")
- };
-
- int frameSampleRate = sampleRateCode switch {
- 0 => SampleRate,
- >= 1 and <= 11 => SampleRateCodes[sampleRateCode],
- 12 => (int)_reader.Read(8) * 1000,
- 13 => (int)_reader.Read(16),
- 14 => (int)_reader.Read(16) * 10,
- _ => throw new InvalidDataException("Reserved sample rate.")
- };
-
- int frameBitsPerSample = bitDepthCode switch {
- 0 => BitsPerSample,
- >= 1 and <= 2 => 8 + ((bitDepthCode - 1) * 4),
- >= 4 and <= 6 => 16 + ((bitDepthCode - 4) * 4),
- 7 => 32,
- _ => throw new InvalidDataException("Reserved bit depth.")
- };
-
- int frameChannelCount = channelLayout switch {
- >= 0 and <= 7 => channelLayout + 1,
- >= 8 and <= 10 => 2,
- _ => throw new InvalidDataException("Reserved channel layout.")
- };
-
- if (frameSampleCount > MaxSamplesPerFrame)
- throw new InvalidDataException("Frame sample count exceeds maximum.");
-
- if (frameSampleRate != SampleRate || frameBitsPerSample != BitsPerSample || frameChannelCount != ChannelCount)
- throw new NotSupportedException("Unsupported audio property change.");
-
- _reader.Skip(8); // Frame header CRC
-
- BufferSampleCount = frameSampleCount;
- RunningSampleCount += frameSampleCount;
- DecodeSubframes(_reader, BitsPerSample, channelLayout, BufferSamples, BufferSampleCount);
- _reader.AlignToByte();
- _reader.Skip(16); // Whole frame CRC
-
- if (_options.ConvertOutputToBytes) {
- _outputHasherTask.Wait();
- ConvertOutputToBytes(BitsPerSample, ChannelCount, BufferSamples, BufferSampleCount, BufferBytes, _options.AllowNonstandardByteOutput);
- BufferByteCount = BufferSampleCount * BlockAlign;
- }
-
- if (_options.ValidateOutputHash) {
- _outputHasherTask = Task.Run(() => {
- _outputHasher!.AppendData(BufferBytes.AsSpan(0, BufferByteCount));
- });
- }
-
- return true;
- }
-
- private static void DecodeSubframes(BitReader reader, int bitsPerSample, int channelLayout, long[][] result, int blockSize) {
- if (channelLayout >= 0 && channelLayout <= 7) {
- for (int ch = 0; ch < result.Length; ch++) {
- DecodeSubframe(reader, bitsPerSample, result[ch].AsSpan(0, blockSize));
- }
- }
- else if (channelLayout >= 8 && channelLayout <= 10) {
- DecodeSubframe(reader, bitsPerSample + (channelLayout == 9 ? 1 : 0), result[0].AsSpan(0, blockSize));
- DecodeSubframe(reader, bitsPerSample + (channelLayout == 9 ? 0 : 1), result[1].AsSpan(0, blockSize));
- if (channelLayout == 8) {
- for (int i = 0; i < blockSize; i++) {
- result[1][i] = result[0][i] - result[1][i];
- }
- }
- else if (channelLayout == 9) {
- for (int i = 0; i < blockSize; i++) {
- result[0][i] += result[1][i];
- }
- }
- else if (channelLayout == 10) {
- for (int i = 0; i < blockSize; i++) {
- long side = result[1][i];
- long right = result[0][i] - (side >> 1);
- result[1][i] = right;
- result[0][i] = right + side;
- }
- }
- }
- else {
- throw new ArgumentOutOfRangeException(nameof(channelLayout));
- }
- }
-
- private static void DecodeSubframe(BitReader reader, int bitsPerSample, Span result) {
- if (reader.Read(1) != 0)
- throw new InvalidDataException("Invalid subframe padding.");
-
- int type = (int)reader.Read(6);
- int shift = (int)reader.Read(1);
- if (shift == 1) {
- while (reader.Read(1) == 0) {
- shift++;
- }
- }
- bitsPerSample -= shift;
-
- if (type == 0) { // Constant coding
- long v = reader.ReadSigned(bitsPerSample);
- for (int i = 0; i < result.Length; i++) {
- result[i] = v;
- }
- }
- else if (type == 1) { // Verbatim coding
- for (int i = 0; i < result.Length; i++) {
- result[i] = reader.ReadSigned(bitsPerSample);
- }
- }
- else if (type >= 8 && type <= 12) {
- DecodeFixedPredictionSubframe(reader, type - 8, bitsPerSample, result);
- }
- else if (type >= 32 && type <= 63) {
- DecodeLinearPredictiveCodingSubframe(reader, type - 31, bitsPerSample, result);
- }
- else {
- throw new InvalidDataException("Reserved subframe type.");
- }
-
- if (shift != 0) {
- for (int i = 0; i < result.Length; i++) {
- result[i] <<= shift;
- }
- }
- }
-
- private static void DecodeFixedPredictionSubframe(BitReader reader, int predOrder, int bitsPerSample, Span result) {
- for (int i = 0; i < predOrder; i++) {
- result[i] = reader.ReadSigned(bitsPerSample);
- }
- DecodeResiduals(reader, predOrder, result);
- if (predOrder != 0) {
- RestoreLinearPrediction(result, FixedPredictionCoefficients[predOrder], 0);
- }
- }
-
- private static void DecodeLinearPredictiveCodingSubframe(BitReader reader, int lpcOrder, int bitsPerSample, Span result) {
- for (int i = 0; i < lpcOrder; i++) {
- result[i] = reader.ReadSigned(bitsPerSample);
- }
- int precision = (int)reader.Read(4) + 1;
- int shift = (int)reader.ReadSigned(5);
- Span coefs = stackalloc long[lpcOrder];
- for (int i = coefs.Length - 1; i >= 0; i--) {
- coefs[i] = reader.ReadSigned(precision);
- }
- DecodeResiduals(reader, lpcOrder, result);
- RestoreLinearPrediction(result, coefs, shift);
- }
-
- private static void DecodeResiduals(BitReader reader, int warmup, Span result) {
- int method = (int)reader.Read(2);
- if (method >= 2)
- throw new InvalidDataException("Reserved residual coding method.");
- int paramBits = method == 0 ? 4 : 5;
- int escapeParam = method == 0 ? 15 : 31;
-
- int partitionOrder = (int)reader.Read(4);
- int numPartitions = 1 << partitionOrder;
- if (result.Length % numPartitions != 0)
- throw new InvalidDataException("Block size not divisible by number of Rice partitions.");
- int partitionSize = result.Length / numPartitions;
-
- for (int i = 0; i < numPartitions; i++) {
- int start = i * partitionSize + (i == 0 ? warmup : 0);
- int end = (i + 1) * partitionSize;
-
- int param = (int)reader.Read(paramBits);
- if (param != escapeParam) {
- for (int j = start; j < end; j++) {
- result[j] = DecodeRice(reader, param);
- }
- }
- else {
- int numBits = (int)reader.Read(5);
- for (int j = start; j < end; j++) {
- result[j] = numBits != 0 ? reader.ReadSigned(numBits) : 0;
- }
- }
- }
- }
-
- private static void RestoreLinearPrediction(Span result, ReadOnlySpan coefs, int shift) {
- for (int i = 0; i < result.Length - coefs.Length; i++) {
- long sum = 0;
- for (int j = 0; j < coefs.Length; j++) {
- sum += result[i + j] * coefs[j];
- }
- result[i + coefs.Length] += sum >> shift;
- }
- }
-
- private static long DecodeRice(BitReader reader, int k) {
- ulong data = reader.RawBuffer;
- int leadingZeroCount = BitOperations.LeadingZeroCount(data);
- int quotientBitCount = leadingZeroCount + 1;
- int fullBitCount = quotientBitCount + k;
- if (fullBitCount > BitReader.BitsAvailableWorstCase) {
- return DecodeRiceFallback(reader, k);
- }
- ulong v = (ulong)leadingZeroCount << k;
- if (k != 0) {
- v |= (data << quotientBitCount) >> (64 - k);
- }
- reader.Skip(fullBitCount);
- // Apply sign from LSB
- return (int)(v >> 1) ^ -(int)(v & 1);
- }
-
- private static long DecodeRiceFallback(BitReader reader, int k) {
- int leadingZeroCount = 0;
- while (reader.Read(1) == 0) {
- leadingZeroCount++;
- }
- ulong v = (ulong)leadingZeroCount << k;
- if (k != 0) {
- v |= reader.Read(k);
- }
- // Apply sign from LSB
- return (int)(v >> 1) ^ -(int)(v & 1);
- }
-
- private static void ConvertOutputToBytes(int bitsPerSample, int channelCount, long[][] samples, int sampleCount, byte[] bytes, bool allowNonstandard) {
- if (!allowNonstandard && (bitsPerSample % 8 != 0 || bitsPerSample == 8)) {
- // Not allowed by default because the output produced here, which targets the byte format
- // specified by FLAC to calculate its MD5 signature, differs from the byte format used in
- // PCM WAV files. For non-whole-byte bit depths, WAV expects the samples to be shifted such
- // that the padding is in the LSBs, and for 8-bit, WAV expects the samples to be unsigned.
- throw new NotSupportedException("Unsupported bit depth.");
- }
- int bytesPerSample = (bitsPerSample + 7) / 8;
- int blockAlign = bytesPerSample * channelCount;
- for (int ch = 0; ch < channelCount; ch++) {
- long[] src = samples[ch];
- int offset = ch * bytesPerSample;
- if (bytesPerSample == 1) {
- for (int i = 0; i < sampleCount; i++) {
- bytes[offset] = (byte)src[i];
- offset += blockAlign;
- }
- }
- else if (bytesPerSample == 2) {
- Span byteSpan = bytes.AsSpan();
- for (int i = 0; i < sampleCount; i++) {
- BinaryPrimitives.WriteInt16LittleEndian(byteSpan.Slice(offset, 2), (short)src[i]);
- offset += blockAlign;
- }
- }
- else if (bytesPerSample == 3) {
- for (int i = 0; i < sampleCount; i++) {
- long s = src[i];
- bytes[offset ] = (byte)s;
- bytes[offset + 1] = (byte)(s >> 8);
- bytes[offset + 2] = (byte)(s >> 16);
- offset += blockAlign;
- }
- }
- else if (bytesPerSample == 4) {
- Span byteSpan = bytes.AsSpan();
- for (int i = 0; i < sampleCount; i++) {
- BinaryPrimitives.WriteInt32LittleEndian(byteSpan.Slice(offset, 4), (int)src[i]);
- offset += blockAlign;
- }
- }
- else {
- throw new NotSupportedException("Unsupported bit depth.");
- }
- }
- }
-
- private static readonly int[] SampleRateCodes = [
- 0, 88200, 176400, 192000, 8000, 16000, 22050, 24000, 32000, 44100, 48000, 96000
- ];
-
- private static readonly long[][] FixedPredictionCoefficients = [
- [],
- [1],
- [-1, 2],
- [1, -3, 3],
- [-1, 4, -6, 4]
- ];
-
- private class BitReader : IDisposable {
- // Buffer replenish logic ensures that a full byte isn't missing
- public const int BitsAvailableWorstCase = 57;
-
- private readonly Stream _stream;
- private ulong _buffer;
- private int _bufferDeficitBits;
- private int _streamOverreadBytes;
-
- public BitReader(Stream stream) {
- _stream = stream;
- _bufferDeficitBits = 64;
- ReplenishBuffer();
- }
-
- public void Dispose() {
- _stream.Dispose();
- }
-
- public bool HasReachedEnd =>
- _streamOverreadBytes >= 8;
-
- public ulong RawBuffer =>
- _buffer;
-
- private void ReplenishBuffer() {
- while (_bufferDeficitBits >= 8) {
- int b = _stream.ReadByte();
- if (b == -1) {
- _streamOverreadBytes++;
- if (HasReachedEnd) {
- if (_bufferDeficitBits == 8) {
- // End was exactly reached; leave deficit so subsequent reads will throw
- return;
- }
- throw new EndOfStreamException();
- }
- }
- else {
- _buffer |= (ulong)b << (_bufferDeficitBits - 8);
- }
- _bufferDeficitBits -= 8;
- }
- }
-
- public void Skip(int numBits) {
- if (numBits < 1 || numBits > BitsAvailableWorstCase)
- throw new ArgumentOutOfRangeException(nameof(numBits));
- _buffer <<= numBits;
- _bufferDeficitBits += numBits;
- ReplenishBuffer();
- }
-
- public ulong Read(int numBits) {
- ulong x = _buffer >> (64 - numBits);
- Skip(numBits);
- return x;
- }
-
- public long ReadSigned(int numBits) {
- ulong x = Read(numBits);
- int shift = 64 - numBits;
- return (long)(x << shift) >> shift;
- }
-
- public void AlignToByte() {
- if (_bufferDeficitBits != 0) {
- Skip(8 - _bufferDeficitBits);
- }
- }
- }
-
- public class Options {
- public bool ConvertOutputToBytes { get; set; } = true;
- public bool ValidateOutputHash { get; set; } = true;
- public bool AllowNonstandardByteOutput { get; set; } = false;
- }
-}
diff --git a/src/Sendspin.SDK/Audio/IAudioDecoder.cs b/src/Sendspin.SDK/Audio/IAudioDecoder.cs
deleted file mode 100644
index 061cd1b..0000000
--- a/src/Sendspin.SDK/Audio/IAudioDecoder.cs
+++ /dev/null
@@ -1,38 +0,0 @@
-//
-// Licensed under the MIT License. See LICENSE file in the project root.
-//
-
-using Sendspin.SDK.Models;
-
-namespace Sendspin.SDK.Audio;
-
-///
-/// Decodes encoded audio frames to PCM samples.
-/// Thread-safe: may be called from WebSocket receive thread.
-///
-public interface IAudioDecoder : IDisposable
-{
- ///
- /// Gets the audio format this decoder was configured for.
- ///
- AudioFormat Format { get; }
-
- ///
- /// Gets the maximum samples that can be output from a single decode call.
- /// Used to pre-allocate buffers. Typically 960*2 for 20ms Opus stereo at 48kHz.
- ///
- int MaxSamplesPerFrame { get; }
-
- ///
- /// Decodes an encoded audio frame to interleaved float samples [-1.0, 1.0].
- ///
- /// Encoded audio data (Opus/FLAC/PCM).
- /// Buffer to receive decoded samples.
- /// Number of samples written (total across all channels).
- int Decode(ReadOnlySpan encodedData, Span decodedSamples);
-
- ///
- /// Resets decoder state (for stream clear/seek operations).
- ///
- void Reset();
-}
diff --git a/src/Sendspin.SDK/Audio/IAudioDecoderFactory.cs b/src/Sendspin.SDK/Audio/IAudioDecoderFactory.cs
deleted file mode 100644
index e33ca0b..0000000
--- a/src/Sendspin.SDK/Audio/IAudioDecoderFactory.cs
+++ /dev/null
@@ -1,28 +0,0 @@
-//
-// Licensed under the MIT License. See LICENSE file in the project root.
-//
-
-using Sendspin.SDK.Models;
-
-namespace Sendspin.SDK.Audio;
-
-///
-/// Factory for creating audio decoders based on format.
-///
-public interface IAudioDecoderFactory
-{
- ///
- /// Creates a decoder for the specified format.
- ///
- /// Audio format from stream/start message.
- /// Configured decoder instance.
- /// If codec is not supported.
- IAudioDecoder Create(AudioFormat format);
-
- ///
- /// Checks if a codec is supported.
- ///
- /// Codec name to check.
- /// True if the codec is supported.
- bool IsSupported(string codec);
-}
diff --git a/src/Sendspin.SDK/Audio/IAudioPipeline.cs b/src/Sendspin.SDK/Audio/IAudioPipeline.cs
deleted file mode 100644
index e80b01a..0000000
--- a/src/Sendspin.SDK/Audio/IAudioPipeline.cs
+++ /dev/null
@@ -1,192 +0,0 @@
-//
-// Licensed under the MIT License. See LICENSE file in the project root.
-//
-
-using Sendspin.SDK.Models;
-using Sendspin.SDK.Protocol;
-
-namespace Sendspin.SDK.Audio;
-
-///
-/// Orchestrates the complete audio pipeline from incoming chunks to output.
-///
-public interface IAudioPipeline : IAsyncDisposable
-{
- ///
- /// Gets the current pipeline state.
- ///
- AudioPipelineState State { get; }
-
- ///
- /// Gets whether the pipeline is ready to accept audio chunks.
- ///
- ///
- /// Returns true when the decoder and buffer have been initialized and can process chunks.
- /// Use this to check before calling to avoid chunk loss.
- ///
- bool IsReady { get; }
-
- ///
- /// Gets the current buffer statistics, or null if not started.
- ///
- AudioBufferStats? BufferStats { get; }
-
- ///
- /// Gets the current audio format being decoded (incoming format), or null if not streaming.
- ///
- ///
- /// This represents the format of the audio stream as received from the server,
- /// before any processing or conversion by the audio pipeline.
- ///
- AudioFormat? CurrentFormat { get; }
-
- ///
- /// Gets the audio format being sent to the output device, or null if not playing.
- ///
- ///
- ///
- /// This represents the format of audio data being written to the audio output device.
- /// In most cases, this matches but with PCM encoding,
- /// as all codecs are decoded to PCM before output.
- ///
- ///
- /// This value is available after the pipeline has started playing.
- ///
- ///
- AudioFormat? OutputFormat => null;
-
- ///
- /// Gets the detected audio output latency in milliseconds.
- /// This value is available after the pipeline has started.
- ///
- ///
- /// This latency represents the buffer delay between when audio is submitted
- /// to the audio output and when it is actually played through the speakers.
- /// It can be used to automatically compensate for audio output delay.
- ///
- int DetectedOutputLatencyMs { get; }
-
- ///
- /// Starts the pipeline with the specified stream format.
- /// Called when stream/start is received.
- ///
- /// Audio format for the stream.
- /// Optional target timestamp for playback alignment.
- /// Cancellation token.
- /// A task representing the async operation.
- Task StartAsync(AudioFormat format, long? targetTimestamp = null, CancellationToken cancellationToken = default);
-
- ///
- /// Stops the pipeline.
- /// Called when stream/end is received.
- ///
- /// A task representing the async operation.
- Task StopAsync();
-
- ///
- /// Notifies the pipeline that a WebSocket reconnect occurred.
- /// Suppresses sync corrections during the reconnect stabilization period.
- ///
- ///
- /// Call this after the clock synchronizer is reset on reconnect. The buffer
- /// and player correction systems will suppress corrections until the Kalman
- /// filter has had time to re-converge (~2 seconds by default).
- ///
- void NotifyReconnect();
-
- ///
- /// Clears the buffer (for seek).
- /// Called when stream/clear is received.
- ///
- /// Optional new target timestamp.
- void Clear(long? newTargetTimestamp = null);
-
- ///
- /// Processes an incoming audio chunk.
- ///
- /// The audio chunk to process.
- void ProcessAudioChunk(AudioChunk chunk);
-
- ///
- /// Sets volume (0-100).
- ///
- /// Volume level.
- void SetVolume(int volume);
-
- ///
- /// Sets mute state.
- ///
- /// Whether to mute.
- void SetMuted(bool muted);
-
- ///
- /// Switches to a different audio output device.
- ///
- /// The device ID to switch to, or null for system default.
- /// Cancellation token.
- /// A task representing the async operation.
- ///
- ///
- /// This will briefly interrupt playback while reinitializing the audio output.
- /// The audio buffer is preserved, so playback resumes from approximately the same position.
- ///
- ///
- /// After switching, the sync timing is re-anchored to account for any timing
- /// discontinuity during the device switch.
- ///
- ///
- Task SwitchDeviceAsync(string? deviceId, CancellationToken cancellationToken = default);
-
- ///
- /// Event raised when pipeline state changes.
- ///
- event EventHandler? StateChanged;
-
- ///
- /// Event raised on pipeline errors.
- ///
- event EventHandler? ErrorOccurred;
-}
-
-///
-/// Audio pipeline states.
-///
-public enum AudioPipelineState
-{
- ///
- /// Pipeline is idle, not processing audio.
- ///
- Idle,
-
- ///
- /// Pipeline is starting up.
- ///
- Starting,
-
- ///
- /// Pipeline is buffering audio before playback.
- ///
- Buffering,
-
- ///
- /// Pipeline is actively playing audio.
- ///
- Playing,
-
- ///
- /// Pipeline is stopping.
- ///
- Stopping,
-
- ///
- /// Pipeline encountered an error.
- ///
- Error,
-}
-
-///
-/// Represents an audio pipeline error.
-///
-/// Error message.
-/// Optional exception that caused the error.
-public record AudioPipelineError(string Message, Exception? Exception = null);
diff --git a/src/Sendspin.SDK/Audio/IAudioPlayer.cs b/src/Sendspin.SDK/Audio/IAudioPlayer.cs
deleted file mode 100644
index 02302df..0000000
--- a/src/Sendspin.SDK/Audio/IAudioPlayer.cs
+++ /dev/null
@@ -1,194 +0,0 @@
-//
-// Licensed under the MIT License. See LICENSE file in the project root.
-//
-
-using Sendspin.SDK.Models;
-
-namespace Sendspin.SDK.Audio;
-
-///
-/// Manages audio output device and playback lifecycle.
-///
-public interface IAudioPlayer : IAsyncDisposable
-{
- ///
- /// Gets the current playback state.
- ///
- AudioPlayerState State { get; }
-
- ///
- /// Gets or sets the output volume (0.0 to 1.0).
- ///
- float Volume { get; set; }
-
- ///
- /// Gets or sets whether output is muted.
- ///
- bool IsMuted { get; set; }
-
- ///
- /// Gets the detected output latency in milliseconds.
- /// This represents the buffer latency of the audio output device.
- ///
- ///
- /// This value is available after completes.
- /// It can be used for informational/diagnostic purposes.
- ///
- int OutputLatencyMs { get; }
-
- ///
- /// Gets the calibrated startup latency in milliseconds for push-model audio backends.
- ///
- ///
- ///
- /// This represents the measured time from first audio write to actual playback start
- /// on push-model backends (like ALSA) that must pre-fill their output buffer.
- ///
- ///
- /// Pull-model backends (like WASAPI) should return 0 since they don't pre-fill.
- ///
- ///
- /// This value is used by to compensate for the
- /// constant negative sync error caused by buffer prefill.
- ///
- ///
- int CalibratedStartupLatencyMs => 0;
-
- ///
- /// Gets the current output audio format, or null if not initialized.
- ///
- ///
- ///
- /// This represents the audio format being sent to the audio output device.
- /// It is available after completes.
- ///
- ///
- /// The output format may differ from the input format if resampling or
- /// format conversion is applied by the audio subsystem.
- ///
- ///
- AudioFormat? OutputFormat => null;
-
- ///
- /// Gets the current playback time from the audio hardware clock in microseconds.
- ///
- ///
- ///
- /// Implementations should return time based on their audio backend's hardware clock,
- /// which is immune to VM wall clock issues:
- ///
- ///
- /// - PortAudio: outputBufferDacTime * 1_000_000
- /// - PulseAudio: pa_stream_get_time()
- /// - ALSA: snd_pcm_htimestamp()
- /// - CoreAudio: AudioTimeStamp.mHostTime
- ///
- ///
- /// Return null if hardware clock is not available (e.g., WASAPI shared mode).
- /// The SDK will fall back to wall clock timing with MonotonicTimer filtering.
- ///
- ///
- ///
- /// Audio hardware clock time in microseconds, or null to use wall clock fallback.
- ///
- long? GetAudioClockMicroseconds() => null;
-
- ///
- /// Notifies the player that a WebSocket reconnect occurred.
- /// Implementations should forward this to their sync correction provider.
- ///
- ///
- /// Default implementation is a no-op. Override in platform-specific players
- /// that use for external sync correction.
- ///
- void NotifyReconnect() { }
-
- ///
- /// Initializes the audio output with the specified format.
- ///
- /// Audio format to use.
- /// Cancellation token.
- /// A task representing the async operation.
- Task InitializeAsync(AudioFormat format, CancellationToken cancellationToken = default);
-
- ///
- /// Sets the sample provider that supplies audio data.
- ///
- /// The audio sample source.
- void SetSampleSource(IAudioSampleSource source);
-
- ///
- /// Starts audio playback.
- ///
- void Play();
-
- ///
- /// Pauses audio playback.
- ///
- void Pause();
-
- ///
- /// Stops audio playback and resets.
- ///
- void Stop();
-
- ///
- /// Switches to a different audio output device.
- ///
- /// The device ID to switch to, or null for system default.
- /// Cancellation token.
- /// A task representing the async operation.
- ///
- /// This will briefly stop playback while reinitializing the audio output.
- /// The sample source is preserved, so playback resumes from the current position.
- ///
- Task SwitchDeviceAsync(string? deviceId, CancellationToken cancellationToken = default);
-
- ///
- /// Event raised when playback state changes.
- ///
- event EventHandler? StateChanged;
-
- ///
- /// Event raised on playback errors.
- ///
- event EventHandler? ErrorOccurred;
-}
-
-///
-/// Audio player playback states.
-///
-public enum AudioPlayerState
-{
- ///
- /// Player has not been initialized.
- ///
- Uninitialized,
-
- ///
- /// Player is stopped.
- ///
- Stopped,
-
- ///
- /// Player is actively playing audio.
- ///
- Playing,
-
- ///
- /// Player is paused.
- ///
- Paused,
-
- ///
- /// Player encountered an error.
- ///
- Error,
-}
-
-///
-/// Represents an audio player error.
-///
-/// Error message.
-/// Optional exception that caused the error.
-public record AudioPlayerError(string Message, Exception? Exception = null);
diff --git a/src/Sendspin.SDK/Audio/IAudioSampleSource.cs b/src/Sendspin.SDK/Audio/IAudioSampleSource.cs
deleted file mode 100644
index 7cf4dee..0000000
--- a/src/Sendspin.SDK/Audio/IAudioSampleSource.cs
+++ /dev/null
@@ -1,29 +0,0 @@
-//
-// Licensed under the MIT License. See LICENSE file in the project root.
-//
-
-using Sendspin.SDK.Models;
-
-namespace Sendspin.SDK.Audio;
-
-///
-/// Provides audio samples to the audio player.
-/// Bridges the timed buffer to NAudio's ISampleProvider.
-///
-public interface IAudioSampleSource
-{
- ///
- /// Gets the audio format.
- ///
- AudioFormat Format { get; }
-
- ///
- /// Reads samples into the buffer.
- /// Called from audio thread - must be fast and non-blocking.
- ///
- /// Buffer to fill.
- /// Offset in buffer.
- /// Number of samples to read.
- /// Samples read (may be less than count on underrun).
- int Read(float[] buffer, int offset, int count);
-}
diff --git a/src/Sendspin.SDK/Audio/ISyncCorrectionProvider.cs b/src/Sendspin.SDK/Audio/ISyncCorrectionProvider.cs
deleted file mode 100644
index 3e5c7c0..0000000
--- a/src/Sendspin.SDK/Audio/ISyncCorrectionProvider.cs
+++ /dev/null
@@ -1,116 +0,0 @@
-//
-// Licensed under the MIT License. See LICENSE file in the project root.
-//
-
-namespace Sendspin.SDK.Audio;
-
-///
-/// Provides sync correction decisions based on sync error from .
-///
-///
-///
-/// This interface abstracts the correction strategy, allowing platforms to implement
-/// their own correction logic. The SDK provides
-/// as a default implementation that mirrors the CLI's tiered correction approach.
-///
-///
-/// Usage pattern:
-/// 1. Call with error values from
-/// 2. Read correction properties (, , )
-/// 3. Apply corrections externally (drop/insert samples, adjust resampler rate)
-/// 4. Call to report applied corrections
-///
-///
-public interface ISyncCorrectionProvider
-{
- ///
- /// Gets the current sync correction mode.
- ///
- SyncCorrectionMode CurrentMode { get; }
-
- ///
- /// Gets the interval for dropping frames (when playing too slow).
- /// Drop one frame every N frames. Zero means no dropping.
- ///
- ///
- /// Only applicable when is .
- /// A smaller value means more aggressive correction.
- ///
- int DropEveryNFrames { get; }
-
- ///
- /// Gets the interval for inserting frames (when playing too fast).
- /// Insert one frame every N frames. Zero means no inserting.
- ///
- ///
- /// Only applicable when is .
- /// A smaller value means more aggressive correction.
- ///
- int InsertEveryNFrames { get; }
-
- ///
- /// Gets the target playback rate for resampling-based sync correction.
- ///
- ///
- ///
- /// Values: 1.0 = normal speed, >1.0 = speed up (behind), <1.0 = slow down (ahead).
- ///
- ///
- /// Only meaningful when is .
- /// The caller should apply this rate to a resampler (e.g., WDL, SoundTouch) for smooth correction.
- ///
- ///
- double TargetPlaybackRate { get; }
-
- ///
- /// Event raised when correction parameters change.
- ///
- ///
- /// Subscribers can use this to update resamplers or other correction components
- /// without polling. The event provides the provider instance for accessing updated values.
- ///
- event Action? CorrectionChanged;
-
- ///
- /// Updates correction decisions based on current sync error values.
- ///
- /// Raw sync error in microseconds from .
- /// Smoothed sync error from .
- ///
- ///
- /// Call this method after each read from .
- /// The provider uses the smoothed error for correction decisions to avoid jittery behavior.
- ///
- ///
- /// Sign convention (same as ):
- /// - Positive = playing behind (need to speed up/drop frames)
- /// - Negative = playing ahead (need to slow down/insert frames)
- ///
- ///
- void UpdateFromSyncError(long rawMicroseconds, double smoothedMicroseconds);
-
- ///
- /// Resets the provider to initial state (no correction).
- ///
- ///
- /// Call this when the buffer is cleared or playback restarts to prevent
- /// stale correction decisions from affecting new playback.
- ///
- void Reset();
-
- ///
- /// Notifies the provider that a WebSocket reconnect occurred.
- ///
- ///
- ///
- /// After a reconnect, the clock synchronizer is reset and needs time to re-converge.
- /// During this stabilization period, sync error measurements are unreliable.
- /// Implementations should suppress corrections until the stabilization period elapses.
- ///
- ///
- /// The stabilization duration is configured via
- /// .
- ///
- ///
- void NotifyReconnect() { }
-}
diff --git a/src/Sendspin.SDK/Audio/ITimedAudioBuffer.cs b/src/Sendspin.SDK/Audio/ITimedAudioBuffer.cs
deleted file mode 100644
index 57a547d..0000000
--- a/src/Sendspin.SDK/Audio/ITimedAudioBuffer.cs
+++ /dev/null
@@ -1,372 +0,0 @@
-//
-// Licensed under the MIT License. See LICENSE file in the project root.
-//
-
-using Sendspin.SDK.Models;
-
-namespace Sendspin.SDK.Audio;
-
-///
-/// Thread-safe circular buffer for timestamped PCM audio.
-/// Handles jitter compensation and timed release of audio samples.
-///
-public interface ITimedAudioBuffer : IDisposable
-{
- ///
- /// Gets the audio format for samples in the buffer.
- ///
- AudioFormat Format { get; }
-
- ///
- /// Gets the sync correction options used by this buffer.
- ///
- ///
- /// Returns a clone of the options to prevent modification after construction.
- ///
- SyncCorrectionOptions SyncOptions { get; }
-
- ///
- /// Gets the current buffer fill level in milliseconds.
- ///
- double BufferedMilliseconds { get; }
-
- ///
- /// Gets or sets the target buffer level in milliseconds (for jitter compensation).
- ///
- double TargetBufferMilliseconds { get; set; }
-
- ///
- /// Gets whether the buffer has enough data to start playback.
- ///
- bool IsReadyForPlayback { get; }
-
- ///
- /// Gets or sets the output latency in microseconds (informational).
- /// This is the delay between when samples are read from the buffer
- /// and when they actually play through the speakers (audio output buffer latency).
- ///
- ///
- /// This value is stored for diagnostic/logging purposes. For sync error compensation,
- /// use which is specifically for
- /// push-model backends that pre-fill their output buffer.
- ///
- long OutputLatencyMicroseconds { get; set; }
-
- ///
- /// Gets or sets the calibrated startup latency in microseconds for push-model audio backends.
- ///
- ///
- ///
- /// This value compensates for audio pre-filled in the output buffer on push-model
- /// backends (like ALSA) where the application must fill the buffer before playback starts.
- /// Without compensation, this prefill causes a constant negative sync error.
- ///
- ///
- /// Set this value only when the audio player has measured/calibrated the actual startup
- /// latency. Pull-model backends (like WASAPI) should leave this at 0.
- ///
- ///
- /// Formula: syncError = elapsed - samplesReadTime + CalibratedStartupLatencyMicroseconds
- ///
- ///
- long CalibratedStartupLatencyMicroseconds { get; set; }
-
- ///
- /// Gets or sets a descriptive name for the timing source used for sync calculations.
- ///
- ///
- /// Set by the pipeline to indicate which timing source is providing timestamps:
- /// "audio-clock" for hardware audio clock, "monotonic" for MonotonicTimer, "wall-clock" for raw timer.
- /// Included in sync correction diagnostic logs to help identify timing-related issues.
- ///
- string? TimingSourceName { get; set; }
-
- ///
- /// Gets the current raw sync error in microseconds.
- /// Positive = behind (need to speed up/drop), Negative = ahead (need to slow down/insert).
- ///
- ///
- /// This is the unsmoothed sync error, updated on every Read/ReadRaw call.
- /// For correction decisions, consider using .
- ///
- long SyncErrorMicroseconds { get; }
-
- ///
- /// Gets the smoothed sync error in microseconds (EMA-filtered).
- /// Positive = behind (need to speed up/drop), Negative = ahead (need to slow down/insert).
- ///
- ///
- /// This is filtered with an exponential moving average to reduce jitter.
- /// Use this value for correction decisions to avoid reacting to transient noise.
- ///
- double SmoothedSyncErrorMicroseconds { get; }
-
- ///
- /// Gets the target playback rate for smooth sync correction via resampling.
- ///
- ///
- ///
- /// Values: 1.0 = normal speed, >1.0 = speed up (behind), <1.0 = slow down (ahead).
- ///
- ///
- /// Range is typically 0.96-1.04 (±4%), but most corrections use 0.98-1.02 (±2%).
- /// This is imperceptible to human ears unlike discrete sample dropping.
- ///
- ///
- [Obsolete("Use SyncErrorMicroseconds with external ISyncCorrectionProvider instead. SDK no longer calculates correction rate.")]
- double TargetPlaybackRate { get; }
-
- ///
- /// Event raised when target playback rate changes.
- /// Subscribers should update their resampler ratio accordingly.
- ///
- [Obsolete("Use SyncErrorMicroseconds with external ISyncCorrectionProvider instead. SDK no longer calculates correction rate.")]
- event Action? TargetPlaybackRateChanged;
-
- ///
- /// Adds decoded audio samples with their target playback timestamp.
- /// Called from decoder thread.
- ///
- /// Interleaved float PCM samples.
- /// Server timestamp (microseconds) when audio should play.
- void Write(ReadOnlySpan samples, long serverTimestamp);
-
- ///
- /// Reads samples that are ready for playback at the current time.
- /// Called from audio output thread. Applies internal sync correction (drop/insert).
- ///
- /// Buffer to fill with samples.
- /// Current local time in microseconds.
- /// Number of samples written.
- ///
- /// This method applies internal sync correction. For external correction control,
- /// use instead and apply correction in the caller.
- ///
- [Obsolete("Use ReadRaw() with external ISyncCorrectionProvider for correction control. This method applies internal correction.")]
- int Read(Span buffer, long currentLocalTime);
-
- ///
- /// Reads samples without applying internal sync correction.
- /// Use this with an external for correction control.
- ///
- /// Buffer to fill with samples.
- /// Current local time in microseconds.
- /// Number of samples written (always matches samples read from buffer).
- ///
- ///
- /// Unlike , this method does NOT apply drop/insert correction.
- /// It still calculates and updates and
- /// .
- ///
- ///
- /// The caller is responsible for:
- /// 1. Reading sync error from this buffer
- /// 2. Calculating correction strategy (via ISyncCorrectionProvider)
- /// 3. Applying correction (drop/insert/resampling) externally
- /// 4. Calling to update tracking
- ///
- ///
- int ReadRaw(Span buffer, long currentLocalTime);
-
- ///
- /// Notifies the buffer that external sync correction was applied.
- /// Call this after applying drop/insert correction externally.
- ///
- /// Number of samples dropped (consumed but not output). Must be non-negative.
- /// Number of samples inserted (output without consuming). Must be non-negative.
- ///
- ///
- /// This updates internal tracking so remains accurate.
- ///
- ///
- /// When dropping: samplesRead cursor advances by droppedCount (we consumed more than output).
- /// When inserting: samplesRead cursor is reduced by insertedCount because
- /// already counted the full read, but inserted samples were duplicated output, not new consumption.
- ///
- ///
- /// Contract: Either OR
- /// should be non-zero, but not both simultaneously. Dropping and inserting in the same correction
- /// cycle is logically invalid. The enforces this by design.
- ///
- ///
- ///
- /// Thrown when or is negative.
- ///
- void NotifyExternalCorrection(int samplesDropped, int samplesInserted);
-
- ///
- /// Notifies the buffer that a WebSocket reconnect occurred.
- ///
- ///
- ///
- /// After reconnect, the clock synchronizer is reset and sync error measurements
- /// become unreliable. This method resets the smoothed sync error (EMA) and
- /// suppresses internal sync corrections during the reconnect stabilization period
- /// configured in .
- ///
- ///
- /// The re-anchor threshold is NOT suppressed — catastrophic drift (>500ms)
- /// is still handled even during stabilization.
- ///
- ///
- void NotifyReconnect();
-
- ///
- /// Clears all buffered audio (for seek/stream clear).
- ///
- void Clear();
-
- ///
- /// Gets buffer statistics for monitoring.
- ///
- /// Current buffer statistics.
- AudioBufferStats GetStats();
-}
-
-///
-/// Statistics for audio buffer monitoring.
-///
-public record AudioBufferStats
-{
- ///
- /// Gets the current buffered time in milliseconds.
- ///
- public double BufferedMs { get; init; }
-
- ///
- /// Gets the target buffer time in milliseconds.
- ///
- public double TargetMs { get; init; }
-
- ///
- /// Gets the number of underrun events (buffer empty when reading).
- ///
- public long UnderrunCount { get; init; }
-
- ///
- /// Gets the number of overrun events (buffer full when writing).
- ///
- public long OverrunCount { get; init; }
-
- ///
- /// Gets the number of samples dropped due to overflow.
- ///
- public long DroppedSamples { get; init; }
-
- ///
- /// Gets the total samples written to the buffer.
- ///
- public long TotalSamplesWritten { get; init; }
-
- ///
- /// Gets the total samples read from the buffer.
- ///
- public long TotalSamplesRead { get; init; }
-
- ///
- /// Gets the current raw sync error in microseconds.
- /// Positive = playing late (behind schedule), Negative = playing early (ahead of schedule).
- ///
- ///
- /// This is the difference between where playback SHOULD be based on elapsed time
- /// versus where it actually IS based on samples output. Used to detect drift
- /// that may require correction via sample drop/insert.
- ///
- public long SyncErrorMicroseconds { get; init; }
-
- ///
- /// Gets the smoothed sync error in microseconds (EMA-filtered).
- ///
- ///
- /// This is the value used for correction decisions, filtered to reduce jitter.
- ///
- public double SmoothedSyncErrorMicroseconds { get; init; }
-
- ///
- /// Gets the raw sync error in milliseconds (convenience property).
- ///
- public double SyncErrorMs => SyncErrorMicroseconds / 1000.0;
-
- ///
- /// Gets the smoothed sync error in milliseconds (convenience property).
- ///
- public double SmoothedSyncErrorMs => SmoothedSyncErrorMicroseconds / 1000.0;
-
- ///
- /// Gets whether playback is currently active.
- ///
- public bool IsPlaybackActive { get; init; }
-
- ///
- /// Gets the number of samples dropped for sync correction (to speed up playback).
- ///
- public long SamplesDroppedForSync { get; init; }
-
- ///
- /// Gets the number of samples inserted for sync correction (to slow down playback).
- ///
- public long SamplesInsertedForSync { get; init; }
-
- ///
- /// Gets the current sync correction mode.
- ///
- public SyncCorrectionMode CurrentCorrectionMode { get; init; }
-
- ///
- /// Gets the target playback rate for resampling-based sync correction.
- ///
- ///
- /// 1.0 = normal, >1.0 = speed up, <1.0 = slow down.
- /// Only meaningful when is .
- ///
- public double TargetPlaybackRate { get; init; } = 1.0;
-
- ///
- /// Gets the total samples read since playback started (for sync debugging).
- ///
- public long SamplesReadSinceStart { get; init; }
-
- ///
- /// Gets the total samples output since playback started (for sync debugging).
- ///
- public long SamplesOutputSinceStart { get; init; }
-
- ///
- /// Gets elapsed time since playback started in milliseconds (for sync debugging).
- ///
- public double ElapsedSinceStartMs { get; init; }
-
- ///
- /// Gets the active timing source name ("audio-clock", "monotonic", or "wall-clock").
- ///
- public string? TimingSourceName { get; init; }
-}
-
-///
-/// Indicates the current sync correction mode.
-///
-public enum SyncCorrectionMode
-{
- ///
- /// No correction needed - sync error within deadband.
- ///
- None,
-
- ///
- /// Using playback rate adjustment via resampling (smooth, imperceptible correction).
- /// This is the preferred mode for small errors (2-15ms).
- ///
- Resampling,
-
- ///
- /// Dropping samples to catch up (playing too slow).
- /// Used for larger errors (>15ms) that need faster correction.
- ///
- Dropping,
-
- ///
- /// Inserting samples to slow down (playing too fast).
- /// Used for larger errors (>15ms) that need faster correction.
- ///
- Inserting,
-}
diff --git a/src/Sendspin.SDK/Audio/SyncCorrectionCalculator.cs b/src/Sendspin.SDK/Audio/SyncCorrectionCalculator.cs
deleted file mode 100644
index cb7d656..0000000
--- a/src/Sendspin.SDK/Audio/SyncCorrectionCalculator.cs
+++ /dev/null
@@ -1,358 +0,0 @@
-//
-// Licensed under the MIT License. See LICENSE file in the project root.
-//
-
-namespace Sendspin.SDK.Audio;
-
-///
-/// Calculates sync correction decisions based on sync error values.
-///
-///
-///
-/// This class implements the tiered correction strategy from the CLI:
-///
-///
-/// - Error < deadband (default 1ms): No correction
-/// - Error in resampling range (default 1-15ms): Proportional playback rate adjustment
-/// - Error > resampling threshold (default 15ms): Frame drop/insert
-///
-///
-/// The calculator is stateless per-update and can be used across threads if synchronized externally.
-/// It maintains correction state internally and raises when parameters change.
-///
-///
-/// Note on IDisposable: This class intentionally does not implement .
-/// It holds no unmanaged resources and does not subscribe to external events. While it provides the
-/// event, subscribers are responsible for unsubscribing in their own
-/// disposal. The owner of this calculator should ensure all subscribers have unsubscribed before
-/// discarding the reference.
-///
-///
-public sealed class SyncCorrectionCalculator : ISyncCorrectionProvider
-{
- private readonly SyncCorrectionOptions _options;
- private readonly int _sampleRate;
- private readonly int _channels;
- private readonly object _lock = new();
-
- // Current correction state
- private SyncCorrectionMode _currentMode = SyncCorrectionMode.None;
- private int _dropEveryNFrames;
- private int _insertEveryNFrames;
- private double _targetPlaybackRate = 1.0;
-
- // Startup tracking
- private long _totalSamplesProcessed;
- private bool _inStartupGracePeriod = true;
-
- // Reconnect stabilization tracking (separate from startup grace to avoid interference)
- private bool _inReconnectStabilization;
- private long _reconnectSamplesProcessed;
-
- ///
- public SyncCorrectionMode CurrentMode
- {
- get { lock (_lock) return _currentMode; }
- }
-
- ///
- public int DropEveryNFrames
- {
- get { lock (_lock) return _dropEveryNFrames; }
- }
-
- ///
- public int InsertEveryNFrames
- {
- get { lock (_lock) return _insertEveryNFrames; }
- }
-
- ///
- public double TargetPlaybackRate
- {
- get { lock (_lock) return _targetPlaybackRate; }
- }
-
- ///
- public event Action? CorrectionChanged;
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// Sync correction options. Uses if null.
- /// Audio sample rate in Hz (e.g., 48000). Must be greater than zero.
- /// Number of audio channels (e.g., 2 for stereo). Must be greater than zero.
- ///
- /// Thrown when or is less than or equal to zero.
- ///
- public SyncCorrectionCalculator(SyncCorrectionOptions? options, int sampleRate, int channels)
- {
- if (sampleRate <= 0)
- {
- throw new ArgumentOutOfRangeException(nameof(sampleRate), sampleRate,
- "Sample rate must be greater than zero.");
- }
-
- if (channels <= 0)
- {
- throw new ArgumentOutOfRangeException(nameof(channels), channels,
- "Channel count must be greater than zero.");
- }
-
- _options = options?.Clone() ?? SyncCorrectionOptions.Default;
- _options.Validate();
- _sampleRate = sampleRate;
- _channels = channels;
- }
-
- ///
- public void UpdateFromSyncError(long rawMicroseconds, double smoothedMicroseconds)
- {
- bool changed;
- lock (_lock)
- {
- changed = UpdateCorrectionInternal(smoothedMicroseconds);
- }
-
- // Fire event outside lock to prevent deadlocks
- if (changed)
- {
- CorrectionChanged?.Invoke(this);
- }
- }
-
- ///
- public void Reset()
- {
- bool changed;
- lock (_lock)
- {
- changed = _currentMode != SyncCorrectionMode.None
- || _dropEveryNFrames != 0
- || _insertEveryNFrames != 0
- || Math.Abs(_targetPlaybackRate - 1.0) > 0.0001;
-
- _currentMode = SyncCorrectionMode.None;
- _dropEveryNFrames = 0;
- _insertEveryNFrames = 0;
- _targetPlaybackRate = 1.0;
- _totalSamplesProcessed = 0;
- _inStartupGracePeriod = true;
- _inReconnectStabilization = false;
- _reconnectSamplesProcessed = 0;
- }
-
- if (changed)
- {
- CorrectionChanged?.Invoke(this);
- }
- }
-
- ///
- /// Notifies the provider that a WebSocket reconnect occurred.
- /// Suppresses corrections during the reconnect stabilization period.
- ///
- ///
- ///
- /// After reconnect, the Kalman clock synchronizer is reset and needs ~2 seconds
- /// to re-converge. During this window, sync error measurements are unreliable.
- /// This method sets a stabilization flag that causes
- /// to return neutral corrections until the period elapses.
- ///
- ///
- /// Multiple rapid reconnects restart the stabilization window each time.
- ///
- ///
- public void NotifyReconnect()
- {
- bool changed;
- lock (_lock)
- {
- changed = _currentMode != SyncCorrectionMode.None
- || _dropEveryNFrames != 0
- || _insertEveryNFrames != 0
- || Math.Abs(_targetPlaybackRate - 1.0) > 0.0001;
-
- _inReconnectStabilization = true;
- _reconnectSamplesProcessed = 0;
- _currentMode = SyncCorrectionMode.None;
- _dropEveryNFrames = 0;
- _insertEveryNFrames = 0;
- _targetPlaybackRate = 1.0;
- }
-
- if (changed)
- {
- CorrectionChanged?.Invoke(this);
- }
- }
-
- ///
- /// Notifies the calculator that samples were processed.
- /// Call this after applying corrections to track startup grace period and reconnect stabilization.
- ///
- /// Number of samples processed.
- public void NotifySamplesProcessed(int samplesProcessed)
- {
- lock (_lock)
- {
- _totalSamplesProcessed += samplesProcessed;
-
- // Check if we've exited the startup grace period
- if (_inStartupGracePeriod)
- {
- var microsecondsPerSample = 1_000_000.0 / (_sampleRate * _channels);
- var elapsedMicroseconds = (long)(_totalSamplesProcessed * microsecondsPerSample);
- if (elapsedMicroseconds >= _options.StartupGracePeriodMicroseconds)
- {
- _inStartupGracePeriod = false;
- }
- }
-
- // Check if we've exited the reconnect stabilization period
- if (_inReconnectStabilization)
- {
- _reconnectSamplesProcessed += samplesProcessed;
- var microsecondsPerSample = 1_000_000.0 / (_sampleRate * _channels);
- var elapsedMicroseconds = (long)(_reconnectSamplesProcessed * microsecondsPerSample);
- if (elapsedMicroseconds >= _options.ReconnectStabilizationMicroseconds)
- {
- _inReconnectStabilization = false;
- }
- }
- }
- }
-
- ///
- /// Updates correction parameters based on smoothed sync error.
- /// Must be called under lock.
- ///
- /// True if correction parameters changed.
- private bool UpdateCorrectionInternal(double smoothedMicroseconds)
- {
- var previousMode = _currentMode;
- var previousDrop = _dropEveryNFrames;
- var previousInsert = _insertEveryNFrames;
- var previousRate = _targetPlaybackRate;
-
- // During startup grace period, don't apply corrections
- if (_inStartupGracePeriod)
- {
- _currentMode = SyncCorrectionMode.None;
- _targetPlaybackRate = 1.0;
- _dropEveryNFrames = 0;
- _insertEveryNFrames = 0;
- return HasChanged(previousMode, previousDrop, previousInsert, previousRate);
- }
-
- // During reconnect stabilization, don't apply corrections
- // (Kalman filter is re-converging, sync error measurements are unreliable)
- if (_inReconnectStabilization)
- {
- _currentMode = SyncCorrectionMode.None;
- _targetPlaybackRate = 1.0;
- _dropEveryNFrames = 0;
- _insertEveryNFrames = 0;
- return HasChanged(previousMode, previousDrop, previousInsert, previousRate);
- }
-
- var absError = Math.Abs(smoothedMicroseconds);
-
- // Thresholds from options
- var deadbandThreshold = _options.EntryDeadbandMicroseconds;
- var resamplingThreshold = _options.ResamplingThresholdMicroseconds;
-
- // Use exit deadband (hysteresis) if we're currently correcting
- if (_currentMode != SyncCorrectionMode.None && absError < _options.ExitDeadbandMicroseconds)
- {
- _currentMode = SyncCorrectionMode.None;
- _targetPlaybackRate = 1.0;
- _dropEveryNFrames = 0;
- _insertEveryNFrames = 0;
- return HasChanged(previousMode, previousDrop, previousInsert, previousRate);
- }
-
- // Tier 1: Deadband - error is small enough to ignore
- if (absError < deadbandThreshold)
- {
- _currentMode = SyncCorrectionMode.None;
- _targetPlaybackRate = 1.0;
- _dropEveryNFrames = 0;
- _insertEveryNFrames = 0;
- return HasChanged(previousMode, previousDrop, previousInsert, previousRate);
- }
-
- // Tier 2: Proportional rate correction (small errors)
- if (absError < resamplingThreshold)
- {
- _currentMode = SyncCorrectionMode.Resampling;
- _dropEveryNFrames = 0;
- _insertEveryNFrames = 0;
-
- // Calculate proportional correction (matching Python CLI approach)
- // Rate = 1.0 + (error_us / target_seconds / 1,000,000)
- var correctionFactor = smoothedMicroseconds
- / _options.CorrectionTargetSeconds
- / 1_000_000.0;
-
- // Clamp to configured maximum speed adjustment
- correctionFactor = Math.Clamp(correctionFactor,
- -_options.MaxSpeedCorrection,
- _options.MaxSpeedCorrection);
-
- _targetPlaybackRate = 1.0 + correctionFactor;
- return HasChanged(previousMode, previousDrop, previousInsert, previousRate);
- }
-
- // Tier 3: Large errors - use frame drop/insert for faster correction
- _targetPlaybackRate = 1.0;
-
- // Calculate desired corrections per second to fix error within target time
- // Error in frames = error_us * sample_rate / 1,000,000
- var framesError = absError * _sampleRate / 1_000_000.0;
- var desiredCorrectionsPerSec = framesError / _options.CorrectionTargetSeconds;
-
- // Calculate frames per second
- var framesPerSecond = (double)_sampleRate;
-
- // Limit correction rate to max speed adjustment
- var maxCorrectionsPerSec = framesPerSecond * _options.MaxSpeedCorrection;
- var actualCorrectionsPerSec = Math.Min(desiredCorrectionsPerSec, maxCorrectionsPerSec);
-
- // Calculate how often to apply a correction (every N frames)
- var correctionInterval = actualCorrectionsPerSec > 0
- ? (int)(framesPerSecond / actualCorrectionsPerSec)
- : 0;
-
- // Minimum interval to prevent too-aggressive correction
- correctionInterval = Math.Max(correctionInterval, _channels * 10);
-
- if (smoothedMicroseconds > 0)
- {
- // Playing too slow - need to drop frames to catch up
- _currentMode = SyncCorrectionMode.Dropping;
- _dropEveryNFrames = correctionInterval;
- _insertEveryNFrames = 0;
- }
- else
- {
- // Playing too fast - need to insert frames to slow down
- _currentMode = SyncCorrectionMode.Inserting;
- _dropEveryNFrames = 0;
- _insertEveryNFrames = correctionInterval;
- }
-
- return HasChanged(previousMode, previousDrop, previousInsert, previousRate);
- }
-
- ///
- /// Checks if correction parameters changed from previous values.
- ///
- private bool HasChanged(SyncCorrectionMode previousMode, int previousDrop, int previousInsert, double previousRate)
- {
- return previousMode != _currentMode
- || previousDrop != _dropEveryNFrames
- || previousInsert != _insertEveryNFrames
- || Math.Abs(previousRate - _targetPlaybackRate) > 0.0001;
- }
-}
diff --git a/src/Sendspin.SDK/Audio/SyncCorrectionOptions.cs b/src/Sendspin.SDK/Audio/SyncCorrectionOptions.cs
deleted file mode 100644
index d750b78..0000000
--- a/src/Sendspin.SDK/Audio/SyncCorrectionOptions.cs
+++ /dev/null
@@ -1,350 +0,0 @@
-//
-// Licensed under the MIT License. See LICENSE file in the project root.
-//
-
-namespace Sendspin.SDK.Audio;
-
-///
-/// Configuration options for audio sync correction in .
-///
-///
-///
-/// These options control how the buffer corrects timing drift between the local clock
-/// and the server's audio stream. The defaults are tuned for Windows WASAPI but may
-/// need adjustment for other platforms (Linux ALSA/PulseAudio, macOS CoreAudio, etc.).
-///
-///
-/// The sync correction uses a tiered approach with proportional rate adjustment:
-///
-/// - Tier 1 (Deadband): Errors below 1ms are ignored
-/// - Tier 2 (Proportional): Errors 1-15ms use proportional rate adjustment
-/// calculated as: rate = 1.0 + (error / / 1,000,000),
-/// clamped to . This matches the Python CLI approach.
-/// - Tier 3 (Drop/Insert): Errors 15ms+ use frame manipulation for faster correction
-/// - Tier 4 (Re-anchor): Errors exceeding trigger buffer clear
-///
-///
-///
-/// Sync error measurements are smoothed using an exponential moving average (EMA) to
-/// filter jitter and prevent oscillation. Proportional correction prevents overshoot
-/// by adjusting rate based on error magnitude rather than using fixed rate steps.
-///
-///
-/// Note: The , ,
-/// and properties are retained for backward compatibility but are no
-/// longer used.
-///
-///
-///
-///
-/// // Typical usage - most options use defaults
-/// var options = new SyncCorrectionOptions
-/// {
-/// CorrectionTargetSeconds = 2.0, // Faster convergence
-/// MaxSpeedCorrection = 0.04, // Allow up to 4% rate adjustment
-/// };
-///
-///
-public sealed class SyncCorrectionOptions
-{
- ///
- /// Gets or sets the error threshold to START correcting (microseconds).
- ///
- ///
- ///
- /// Deprecated: This property is no longer used. The sync correction now uses
- /// fixed discrete thresholds matching the JS library: 1ms deadband, 8ms for 1% correction,
- /// 15ms for 2% correction.
- ///
- ///
- /// Retained for backward compatibility. Default: 2000 (2ms).
- ///
- ///
- [Obsolete("No longer used. Discrete thresholds are now fixed to match JS library.")]
- public long EntryDeadbandMicroseconds { get; set; } = 2_000;
-
- ///
- /// Gets or sets the error threshold to STOP correcting (microseconds).
- ///
- ///
- ///
- /// Deprecated: This property is no longer used. The hysteresis deadband has been
- /// removed in favor of EMA smoothing and discrete rate steps, which provide more stable
- /// correction without oscillation.
- ///
- ///
- /// Retained for backward compatibility. Default: 500 (0.5ms).
- ///
- ///
- [Obsolete("No longer used. Hysteresis replaced by EMA smoothing.")]
- public long ExitDeadbandMicroseconds { get; set; } = 500;
-
- ///
- /// Gets or sets the maximum playback rate adjustment (0.0 to 1.0).
- ///
- ///
- ///
- /// Controls how aggressively playback speed is adjusted. A value of 0.02 means
- /// rates between 0.98x and 1.02x (2% adjustment). Human pitch perception threshold
- /// is approximately 3%, so values up to 0.04 are generally imperceptible.
- ///
- ///
- /// Lower values reduce oscillation on platforms with timing jitter but correct more slowly.
- /// The Python CLI uses 0.04 (4%), while Windows defaults to 0.02 (2%) for stability.
- ///
- ///
- /// Default: 0.02 (2%).
- ///
- ///
- public double MaxSpeedCorrection { get; set; } = 0.02;
-
- ///
- /// Gets or sets the target time to eliminate drift (seconds).
- ///
- ///
- /// Controls how quickly the sync error should be corrected. Lower values are more
- /// aggressive and correct faster, but may overshoot on platforms with timing jitter.
- /// The Python CLI uses 2.0 seconds.
- /// Default: 3.0 seconds.
- ///
- public double CorrectionTargetSeconds { get; set; } = 3.0;
-
- ///
- /// Gets or sets the threshold between resampling and drop/insert correction (microseconds).
- ///
- ///
- ///
- /// Errors below this threshold use smooth playback rate adjustment (resampling),
- /// which is imperceptible. Errors above use frame drop/insert for faster correction,
- /// which may cause minor audio artifacts.
- ///
- ///
- /// Default: 15000 (15ms).
- ///
- ///
- public long ResamplingThresholdMicroseconds { get; set; } = 15_000;
-
- ///
- /// Gets or sets the threshold for re-anchoring (microseconds).
- ///
- ///
- /// When sync error exceeds this threshold, the buffer is cleared and sync is restarted.
- /// This handles catastrophic drift that cannot be corrected incrementally.
- /// Default: 500000 (500ms).
- ///
- public long ReanchorThresholdMicroseconds { get; set; } = 500_000;
-
- ///
- /// Gets or sets the minimum time between re-anchors (microseconds).
- ///
- ///
- ///
- /// Prevents rapid repeated re-anchors when the clock synchronizer has persistent error
- /// (e.g., during reconnect re-convergence or network instability). Without a cooldown,
- /// re-anchors can trigger every ~750ms (500ms grace + 250ms rebuffer), causing audio stuttering.
- ///
- ///
- /// This value matches the Android client's REANCHOR_COOLDOWN_US and the Python CLI's
- /// _REANCHOR_COOLDOWN_US.
- /// Default: 5000000 (5 seconds).
- ///
- ///
- public long ReanchorCooldownMicroseconds { get; set; } = 5_000_000;
-
- ///
- /// Gets or sets the startup grace period (microseconds).
- ///
- ///
- /// No sync correction is applied during this initial period after playback starts.
- /// This allows playback to stabilize before measuring drift, preventing false
- /// corrections due to initial timing jitter.
- /// Default: 500000 (500ms).
- ///
- public long StartupGracePeriodMicroseconds { get; set; } = 500_000;
-
- ///
- /// Gets or sets the reconnect stabilization period (microseconds).
- ///
- ///
- ///
- /// After a WebSocket reconnect, the clock synchronizer is reset and needs time to
- /// re-converge. During this window, sync error measurements are unreliable because
- /// they are based on a freshly-reset Kalman filter with high uncertainty.
- ///
- ///
- /// Without suppression, the sync correction system reacts to these inaccurate
- /// measurements, causing erratic drop/insert or resampling corrections that produce
- /// audible artifacts.
- ///
- ///
- /// This value matches the Android client's RECONNECT_STABILIZATION_US.
- /// Default: 2000000 (2 seconds).
- ///
- ///
- public long ReconnectStabilizationMicroseconds { get; set; } = 2_000_000;
-
- ///
- /// Gets or sets the grace window for scheduled start (microseconds).
- ///
- ///
- /// Playback starts when current time is within this window of the scheduled start time.
- /// This compensates for audio callback timing granularity that might cause starting
- /// slightly late.
- /// Default: 10000 (10ms).
- ///
- public long ScheduledStartGraceWindowMicroseconds { get; set; } = 10_000;
-
- ///
- /// Gets or sets whether to bypass the deadband entirely.
- ///
- ///
- ///
- /// Deprecated: This property is no longer used. The sync correction now uses
- /// EMA-smoothed error values with discrete rate steps, which eliminates the need for
- /// deadband bypass. The EMA filter provides natural smoothing that handles small
- /// errors without requiring continuous micro-adjustments.
- ///
- ///
- /// Retained for backward compatibility. Default: false.
- ///
- ///
- [Obsolete("No longer used. EMA smoothing replaces the need for deadband bypass.")]
- public bool BypassDeadband { get; set; }
-
- ///
- /// Gets the minimum playback rate (1.0 - MaxSpeedCorrection).
- ///
- public double MinRate => 1.0 - MaxSpeedCorrection;
-
- ///
- /// Gets the maximum playback rate (1.0 + MaxSpeedCorrection).
- ///
- public double MaxRate => 1.0 + MaxSpeedCorrection;
-
- ///
- /// Validates the options and throws if invalid.
- ///
- /// Thrown when options are invalid.
- public void Validate()
- {
- if (EntryDeadbandMicroseconds < 0)
- {
- throw new ArgumentException(
- "EntryDeadbandMicroseconds must be non-negative.",
- nameof(EntryDeadbandMicroseconds));
- }
-
- if (ExitDeadbandMicroseconds < 0)
- {
- throw new ArgumentException(
- "ExitDeadbandMicroseconds must be non-negative.",
- nameof(ExitDeadbandMicroseconds));
- }
-
- if (ExitDeadbandMicroseconds >= EntryDeadbandMicroseconds && !BypassDeadband)
- {
- throw new ArgumentException(
- "ExitDeadbandMicroseconds must be less than EntryDeadbandMicroseconds to create hysteresis.",
- nameof(ExitDeadbandMicroseconds));
- }
-
- if (MaxSpeedCorrection is <= 0 or > 1.0)
- {
- throw new ArgumentException(
- "MaxSpeedCorrection must be between 0 (exclusive) and 1.0 (inclusive).",
- nameof(MaxSpeedCorrection));
- }
-
- if (CorrectionTargetSeconds <= 0)
- {
- throw new ArgumentException(
- "CorrectionTargetSeconds must be positive.",
- nameof(CorrectionTargetSeconds));
- }
-
- if (ResamplingThresholdMicroseconds < 0)
- {
- throw new ArgumentException(
- "ResamplingThresholdMicroseconds must be non-negative.",
- nameof(ResamplingThresholdMicroseconds));
- }
-
- if (ReanchorThresholdMicroseconds <= ResamplingThresholdMicroseconds)
- {
- throw new ArgumentException(
- "ReanchorThresholdMicroseconds must be greater than ResamplingThresholdMicroseconds.",
- nameof(ReanchorThresholdMicroseconds));
- }
-
- if (ReanchorCooldownMicroseconds < 0)
- {
- throw new ArgumentException(
- "ReanchorCooldownMicroseconds must be non-negative.",
- nameof(ReanchorCooldownMicroseconds));
- }
-
- if (StartupGracePeriodMicroseconds < 0)
- {
- throw new ArgumentException(
- "StartupGracePeriodMicroseconds must be non-negative.",
- nameof(StartupGracePeriodMicroseconds));
- }
-
- if (ScheduledStartGraceWindowMicroseconds < 0)
- {
- throw new ArgumentException(
- "ScheduledStartGraceWindowMicroseconds must be non-negative.",
- nameof(ScheduledStartGraceWindowMicroseconds));
- }
-
- if (ReconnectStabilizationMicroseconds < 0)
- {
- throw new ArgumentException(
- "ReconnectStabilizationMicroseconds must be non-negative.",
- nameof(ReconnectStabilizationMicroseconds));
- }
- }
-
- ///
- /// Creates a copy of these options.
- ///
- /// A new instance with the same values.
- public SyncCorrectionOptions Clone() => new()
- {
- EntryDeadbandMicroseconds = EntryDeadbandMicroseconds,
- ExitDeadbandMicroseconds = ExitDeadbandMicroseconds,
- MaxSpeedCorrection = MaxSpeedCorrection,
- CorrectionTargetSeconds = CorrectionTargetSeconds,
- ResamplingThresholdMicroseconds = ResamplingThresholdMicroseconds,
- ReanchorThresholdMicroseconds = ReanchorThresholdMicroseconds,
- ReanchorCooldownMicroseconds = ReanchorCooldownMicroseconds,
- StartupGracePeriodMicroseconds = StartupGracePeriodMicroseconds,
- ScheduledStartGraceWindowMicroseconds = ScheduledStartGraceWindowMicroseconds,
- ReconnectStabilizationMicroseconds = ReconnectStabilizationMicroseconds,
- BypassDeadband = BypassDeadband,
- };
-
- ///
- /// Gets the default options (matching current Windows behavior).
- ///
- public static SyncCorrectionOptions Default => new();
-
- ///
- /// Gets options matching the Python CLI defaults (more aggressive).
- ///
- ///
- /// The CLI uses tighter tolerances and faster correction, which works well
- /// on platforms with precise timing (hardware audio interfaces, etc.).
- ///
- public static SyncCorrectionOptions CliDefaults => new()
- {
- EntryDeadbandMicroseconds = 2_000,
- ExitDeadbandMicroseconds = 500,
- MaxSpeedCorrection = 0.04, // 4% vs Windows 2%
- CorrectionTargetSeconds = 2.0, // 2s vs Windows 3s
- ResamplingThresholdMicroseconds = 15_000,
- ReanchorThresholdMicroseconds = 500_000,
- StartupGracePeriodMicroseconds = 500_000,
- ScheduledStartGraceWindowMicroseconds = 10_000,
- };
-}
diff --git a/src/Sendspin.SDK/Audio/TimedAudioBuffer.cs b/src/Sendspin.SDK/Audio/TimedAudioBuffer.cs
deleted file mode 100644
index e8b129c..0000000
--- a/src/Sendspin.SDK/Audio/TimedAudioBuffer.cs
+++ /dev/null
@@ -1,1537 +0,0 @@
-//
-// Licensed under the MIT License. See LICENSE file in the project root.
-//
-
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Logging.Abstractions;
-using Sendspin.SDK.Models;
-using Sendspin.SDK.Synchronization;
-
-namespace Sendspin.SDK.Audio;
-
-///
-/// Thread-safe circular buffer that releases audio at the correct time based on server timestamps.
-/// Uses IClockSynchronizer to convert server timestamps to local playback times.
-///
-///
-///
-/// This buffer implements a producer-consumer pattern where:
-/// - The WebSocket receive thread writes decoded audio with server timestamps
-/// - The NAudio audio thread reads samples when their playback time arrives
-///
-///
-/// Timing strategy:
-/// - Each write associates samples with a server timestamp (when they should play)
-/// - On read, we check if the oldest segment's playback time has arrived
-/// - If not ready, we output silence (prevents playing audio too early)
-/// - If past due, we play immediately (catches up on delayed audio)
-///
-///
-public sealed class TimedAudioBuffer : ITimedAudioBuffer
-{
- private readonly ILogger _logger;
- private readonly IClockSynchronizer _clockSync;
- private readonly SyncCorrectionOptions _syncOptions;
- private readonly object _lock = new();
-
- // Rate limiting for underrun/overrun logging (microseconds)
- private const long UnderrunLogIntervalMicroseconds = 1_000_000; // Log at most once per second
- private long _lastUnderrunLogTime;
- private long _underrunsSinceLastLog;
-
- // Circular buffer for samples
- private float[] _buffer;
- private int _writePos;
- private int _readPos;
- private int _count;
-
- // Timestamp tracking - maps sample ranges to their playback times
- private readonly Queue _segments;
- private long _nextExpectedPlaybackTime;
- private bool _playbackStarted;
-
- // Configuration
- private readonly int _sampleRate;
- private readonly int _channels;
- private readonly int _samplesPerMs;
-
- // Sync correction state
- private int _dropEveryNFrames; // Drop a frame every N frames (when playing too slow)
- private int _insertEveryNFrames; // Insert a frame every N frames (when playing too fast)
- private int _framesSinceLastCorrection; // Counter for applying corrections
- private long _samplesDroppedForSync; // Total samples dropped for sync correction
- private long _samplesInsertedForSync; // Total samples inserted for sync correction
- private bool _needsReanchor; // Flag to trigger re-anchoring
- private long _lastReanchorTimeMicroseconds; // Local time of last reanchor (persists across Clear)
- private long _lastReanchorCooldownLogTime; // Rate-limit cooldown suppression logging
- private int _reanchorEventPending; // 0 = not pending, 1 = pending (for thread-safe event coalescing)
- private float[]? _lastOutputFrame; // Last output frame for smooth drop/insert (Python CLI approach)
-
- // Correction mode transition tracking (for diagnostic logging)
- private SyncCorrectionMode _previousCorrectionMode = SyncCorrectionMode.None;
- private long _correctionStartTimeUs; // When current correction session started
- private long _droppedAtSessionStart; // Samples dropped count at start of drop session
- private long _insertedAtSessionStart; // Samples inserted count at start of insert session
-
- // Statistics
- private long _underrunCount;
- private long _overrunCount;
- private long _droppedSamples;
- private long _totalWritten;
- private long _totalRead;
-
- // Scheduled start: when playback should begin (supports static delay feature)
- // The first segment's LocalPlaybackTime includes any static delay from IClockSynchronizer.
- // We wait until this time arrives before outputting audio.
- private long _scheduledStartLocalTime; // Target local time when playback should start (μs)
- private bool _waitingForScheduledStart; // True while waiting for scheduled start time
-
- // Sync error tracking (CLI-style: track samples READ, not samples OUTPUT)
- // Key insight: We must track samples READ from buffer, not samples OUTPUT.
- // When dropping, we read MORE than we output → samplesReadTime advances → error shrinks.
- // When inserting, we read NOTHING → samplesReadTime stays → error grows toward 0.
- private long _playbackStartLocalTime; // Local time when playback actually started (μs)
- private long _lastElapsedMicroseconds; // Last calculated elapsed time (for stats)
- private long _currentSyncErrorMicroseconds; // Positive = behind (need DROP), Negative = ahead (need INSERT)
- private double _smoothedSyncErrorMicroseconds; // EMA-filtered sync error for stable correction decisions
- private long _samplesReadSinceStart; // Total samples READ (consumed) since playback started
- private long _samplesOutputSinceStart; // Total samples OUTPUT since playback started (for stats)
- private double _microsecondsPerSample; // Duration of one sample in microseconds
-
- // Sync error smoothing (matches JS library approach)
- // EMA filter prevents jittery correction decisions from measurement noise.
- // Alpha of 0.1 means ~10 updates to reach 63% of a step change.
- // At ~10ms audio callbacks, this is ~100ms to stabilize after a change.
- private const double SyncErrorSmoothingAlpha = 0.1;
-
- // Reconnect stabilization: suppress corrections while Kalman filter re-converges
- private bool _inReconnectStabilization;
- private long _reconnectStabilizationStartOutput;
-
- private bool _disposed;
-
- ///
- public AudioFormat Format { get; }
-
- ///
- public SyncCorrectionOptions SyncOptions => _syncOptions.Clone();
-
- ///
- /// Event raised when sync error is too large and re-anchoring is needed.
- /// The pipeline should clear the buffer and restart synchronized playback.
- ///
- public event EventHandler? ReanchorRequired;
-
- ///
- public double TargetBufferMilliseconds { get; set; } = 250;
-
- ///
- public double TargetPlaybackRate { get; private set; } = 1.0;
-
- ///
- public event Action? TargetPlaybackRateChanged;
-
- ///
- public double BufferedMilliseconds
- {
- get
- {
- lock (_lock)
- {
- return _count / (double)_samplesPerMs;
- }
- }
- }
-
- ///
- public bool IsReadyForPlayback
- {
- get
- {
- lock (_lock)
- {
- // Ready when we have at least 80% of target buffer
- return BufferedMilliseconds >= TargetBufferMilliseconds * 0.8;
- }
- }
- }
-
- ///
- public long OutputLatencyMicroseconds { get; set; }
-
- ///
- public long CalibratedStartupLatencyMicroseconds { get; set; }
-
- ///
- public string? TimingSourceName { get; set; }
-
- ///
- public long SyncErrorMicroseconds
- {
- get
- {
- lock (_lock)
- {
- return _currentSyncErrorMicroseconds;
- }
- }
- }
-
- ///
- public double SmoothedSyncErrorMicroseconds
- {
- get
- {
- lock (_lock)
- {
- return _smoothedSyncErrorMicroseconds;
- }
- }
- }
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// Audio format for samples.
- /// Clock synchronizer for timestamp conversion.
- /// Maximum buffer capacity in milliseconds. Should be large enough to absorb the server's initial burst. Compact codecs like OPUS can burst 40-60+ seconds; 120s recommended.
- /// Optional sync correction options. Uses if not provided.
- /// Optional logger for diagnostics (uses NullLogger if not provided).
- public TimedAudioBuffer(
- AudioFormat format,
- IClockSynchronizer clockSync,
- int bufferCapacityMs = 500,
- SyncCorrectionOptions? syncOptions = null,
- ILogger? logger = null)
- {
- ArgumentNullException.ThrowIfNull(format);
- ArgumentNullException.ThrowIfNull(clockSync);
-
- _logger = logger ?? NullLogger.Instance;
- Format = format;
- _clockSync = clockSync;
- _syncOptions = syncOptions?.Clone() ?? SyncCorrectionOptions.Default;
- _syncOptions.Validate();
- _sampleRate = format.SampleRate;
- _channels = format.Channels;
- _samplesPerMs = (_sampleRate * _channels) / 1000;
-
- // Pre-allocate buffer for specified duration
- var bufferSamples = bufferCapacityMs * _samplesPerMs;
- _buffer = new float[bufferSamples];
- _segments = new Queue();
-
- // Calculate microseconds per interleaved sample (for sync error calculation)
- // For stereo 48kHz: 1,000,000 / (48000 * 2) = ~10.42 μs per sample
- _microsecondsPerSample = 1_000_000.0 / (_sampleRate * _channels);
- }
-
- ///
- public void Write(ReadOnlySpan samples, long serverTimestamp)
- {
- ObjectDisposedException.ThrowIf(_disposed, this);
-
- if (samples.IsEmpty)
- {
- return;
- }
-
- lock (_lock)
- {
- // Convert server timestamp to local playback time
- var localPlaybackTime = _clockSync.ServerToClientTime(serverTimestamp);
-
- // Check for overrun
- if (_count + samples.Length > _buffer.Length)
- {
- _overrunCount++;
-
- if (!_playbackStarted)
- {
- // Before playback starts, discard INCOMING audio to preserve the stream's
- // starting position. The server's initial burst can far exceed buffer capacity
- // (especially for compact codecs like OPUS), and dropping the oldest audio
- // would destroy the beginning of the stream — causing the player to start
- // from the wrong position (potentially tens of seconds into the song).
- if (_overrunCount <= 3 || _overrunCount % 500 == 0)
- {
- _logger.LogDebug(
- "[Buffer] Pre-playback overrun #{Count}: discarding incoming {ChunkMs:F1}ms to preserve stream start (buffer full at {CapacityMs}ms)",
- _overrunCount,
- samples.Length / (double)_samplesPerMs,
- _buffer.Length / (double)_samplesPerMs);
- }
-
- return; // Discard incoming chunk — do NOT drop oldest
- }
-
- // During playback, drop oldest to make room (normal overrun behavior)
- var toDrop = (_count + samples.Length) - _buffer.Length;
- DropOldestSamples(toDrop);
- _logger.LogDebug(
- "[Buffer] Overrun #{Count}: dropped {DroppedMs:F1}ms of oldest audio (buffer full at {CapacityMs}ms)",
- _overrunCount,
- toDrop / (double)_samplesPerMs,
- _buffer.Length / (double)_samplesPerMs);
- }
-
- // Write samples to circular buffer
- WriteSamplesToBuffer(samples);
-
- // Track this segment's timestamp
- _segments.Enqueue(new TimestampedSegment(localPlaybackTime, samples.Length));
- _count += samples.Length;
- _totalWritten += samples.Length;
- }
- }
-
- ///
- public int Read(Span buffer, long currentLocalTime)
- {
- ObjectDisposedException.ThrowIf(_disposed, this);
-
- lock (_lock)
- {
- // If buffer is empty, output silence
- if (_count == 0)
- {
- if (_playbackStarted)
- {
- _underrunCount++;
- _underrunsSinceLastLog++;
- LogUnderrunIfNeeded(currentLocalTime);
- }
-
- buffer.Fill(0f);
- return 0;
- }
-
- // Scheduled start logic: wait for the target playback time before starting
- // This enables the StaticDelayMs feature to work correctly.
- //
- // The first segment's LocalPlaybackTime includes any static delay from
- // IClockSynchronizer.ServerToClientTime(). By waiting for this time to arrive,
- // positive static delay causes us to start later (as intended).
- //
- // Without this, we'd start immediately and the static delay would only affect
- // sync error calculation, which can't handle large offsets (exceeds re-anchor threshold).
- if (_segments.Count > 0 && !_playbackStarted)
- {
- var firstSegment = _segments.Peek();
-
- // First time seeing audio: capture the scheduled start time
- if (!_waitingForScheduledStart)
- {
- _scheduledStartLocalTime = firstSegment.LocalPlaybackTime;
- _waitingForScheduledStart = true;
- _nextExpectedPlaybackTime = firstSegment.LocalPlaybackTime;
- }
-
- // Check if we've reached the scheduled start time (with grace window)
- var timeUntilStart = _scheduledStartLocalTime - currentLocalTime;
- if (timeUntilStart > _syncOptions.ScheduledStartGraceWindowMicroseconds)
- {
- // Not ready yet - output silence and wait
- buffer.Fill(0f);
- return 0;
- }
-
- // If scheduled time is significantly in the past, skip forward to near-current audio.
- if (timeUntilStart < -_syncOptions.ScheduledStartGraceWindowMicroseconds)
- {
- SkipStaleAudio(currentLocalTime);
- }
-
- _logger.LogInformation(
- "[Buffer] Playback starting (Read): timeUntilStart={TimeUntilStart:F1}ms, " +
- "buffered={BufferedMs:F0}ms, segments={Segments}, scheduledStart={Scheduled}",
- timeUntilStart / 1000.0, _count / (double)_samplesPerMs,
- _segments.Count, _scheduledStartLocalTime);
-
- // Scheduled time arrived - start playback!
- _playbackStarted = true;
- _waitingForScheduledStart = false;
-
- // Initialize sync error tracking (CLI-style: track samples READ)
- //
- // For push-model backends (ALSA), we've already consumed samples to pre-fill
- // the output buffer before playback starts. By backdating the anchor by the
- // startup latency, elapsed time matches the samples we've already read.
- //
- // sync_error = elapsedWallClock - samplesReadTime
- // Positive = wall clock ahead = playing too slow = DROP to catch up
- // Negative = wall clock behind = playing too fast = INSERT to slow down
- //
- // This handles static buffer fill time architecturally, so sync correction
- // only needs to handle drift and fluctuations.
- _playbackStartLocalTime = currentLocalTime - CalibratedStartupLatencyMicroseconds;
- _samplesReadSinceStart = 0;
- _samplesOutputSinceStart = 0;
- }
-
- // Check for re-anchor condition before reading
- if (_needsReanchor)
- {
- _needsReanchor = false;
- buffer.Fill(0f);
-
- // Raise event outside of lock to prevent deadlocks.
- // Use Interlocked to ensure only one event can be pending at a time,
- // preventing duplicate events from queuing up if Read() is called rapidly.
- if (Interlocked.CompareExchange(ref _reanchorEventPending, 1, 0) == 0)
- {
- try
- {
- Task.Run(() =>
- {
- try
- {
- ReanchorRequired?.Invoke(this, EventArgs.Empty);
- }
- finally
- {
- Interlocked.Exchange(ref _reanchorEventPending, 0);
- }
- });
- }
- catch
- {
- // Task.Run can throw (e.g., ThreadPool exhaustion, OutOfMemoryException).
- // Reset the pending flag so future re-anchor events are not blocked.
- Interlocked.Exchange(ref _reanchorEventPending, 0);
- throw;
- }
- }
-
- return 0;
- }
-
- // Calculate how many samples we want to read, potentially adjusted for sync correction
- var toRead = Math.Min(buffer.Length, _count);
-
- // Apply sync correction: drop or insert frames
- var (actualRead, outputCount) = ReadWithSyncCorrection(buffer, toRead);
-
- _count -= actualRead;
- _totalRead += actualRead;
-
- // Update segment tracking
- ConsumeSegments(actualRead);
-
- // Update sync error tracking and correction rate (CLI-style approach)
- // IMPORTANT: Track both samplesRead AND samplesOutput separately!
- // - samplesRead advances the server cursor (what timestamp we're reading)
- // - samplesOutput advances wall clock (how much time has passed for output)
- // When dropping: read 2, output 1 → cursor advances faster → error shrinks ✓
- // When inserting: read 0, output 1 → cursor stays still → error grows toward 0 ✓
- if (_playbackStarted && outputCount > 0)
- {
- _samplesReadSinceStart += actualRead;
- _samplesOutputSinceStart += outputCount;
-
- CalculateSyncError(currentLocalTime);
- UpdateCorrectionRate();
-
- // Check if error is too large and we need to re-anchor
- // But skip this check during startup grace period
- var elapsedSinceStart = (long)(_samplesOutputSinceStart * _microsecondsPerSample);
- if (elapsedSinceStart >= _syncOptions.StartupGracePeriodMicroseconds
- && Math.Abs(_currentSyncErrorMicroseconds) > _syncOptions.ReanchorThresholdMicroseconds)
- {
- if (currentLocalTime - _lastReanchorTimeMicroseconds >= _syncOptions.ReanchorCooldownMicroseconds)
- {
- _lastReanchorTimeMicroseconds = currentLocalTime;
- _needsReanchor = true;
- }
- else if (currentLocalTime - _lastReanchorCooldownLogTime >= UnderrunLogIntervalMicroseconds)
- {
- _lastReanchorCooldownLogTime = currentLocalTime;
- _logger.LogWarning(
- "[Correction] Reanchor suppressed by cooldown ({CooldownMs}ms remaining)",
- (_syncOptions.ReanchorCooldownMicroseconds - (currentLocalTime - _lastReanchorTimeMicroseconds)) / 1000);
- }
- }
- }
-
- // Fill remainder with silence if we didn't have enough
- if (outputCount < buffer.Length)
- {
- buffer.Slice(outputCount).Fill(0f);
- }
-
- return outputCount;
- }
- }
-
- ///
- public int ReadRaw(Span buffer, long currentLocalTime)
- {
- ObjectDisposedException.ThrowIf(_disposed, this);
-
- lock (_lock)
- {
- // If buffer is empty, output silence
- if (_count == 0)
- {
- if (_playbackStarted)
- {
- _underrunCount++;
- _underrunsSinceLastLog++;
- LogUnderrunIfNeeded(currentLocalTime);
- }
-
- buffer.Fill(0f);
- return 0;
- }
-
- // Scheduled start logic (same as Read)
- if (_segments.Count > 0 && !_playbackStarted)
- {
- var firstSegment = _segments.Peek();
-
- if (!_waitingForScheduledStart)
- {
- _scheduledStartLocalTime = firstSegment.LocalPlaybackTime;
- _waitingForScheduledStart = true;
- _nextExpectedPlaybackTime = firstSegment.LocalPlaybackTime;
- }
-
- var timeUntilStart = _scheduledStartLocalTime - currentLocalTime;
- if (timeUntilStart > _syncOptions.ScheduledStartGraceWindowMicroseconds)
- {
- buffer.Fill(0f);
- return 0;
- }
-
- // Skip stale audio if scheduled time is in the past
- if (timeUntilStart < -_syncOptions.ScheduledStartGraceWindowMicroseconds)
- {
- SkipStaleAudio(currentLocalTime);
- }
-
- _logger.LogInformation(
- "[Buffer] Playback starting: timeUntilStart={TimeUntilStart:F1}ms, " +
- "buffered={BufferedMs:F0}ms, segments={Segments}, scheduledStart={Scheduled}",
- timeUntilStart / 1000.0, _count / (double)_samplesPerMs,
- _segments.Count, _scheduledStartLocalTime);
-
- _playbackStarted = true;
- _waitingForScheduledStart = false;
- _playbackStartLocalTime = currentLocalTime - CalibratedStartupLatencyMicroseconds;
- _samplesReadSinceStart = 0;
- _samplesOutputSinceStart = 0;
- }
-
- // Check for re-anchor condition
- if (_needsReanchor)
- {
- _needsReanchor = false;
- buffer.Fill(0f);
-
- if (Interlocked.CompareExchange(ref _reanchorEventPending, 1, 0) == 0)
- {
- try
- {
- Task.Run(() =>
- {
- try
- {
- ReanchorRequired?.Invoke(this, EventArgs.Empty);
- }
- finally
- {
- Interlocked.Exchange(ref _reanchorEventPending, 0);
- }
- });
- }
- catch
- {
- // Task.Run can throw (e.g., ThreadPool exhaustion, OutOfMemoryException).
- // Reset the pending flag so future re-anchor events are not blocked.
- Interlocked.Exchange(ref _reanchorEventPending, 0);
- throw;
- }
- }
-
- return 0;
- }
-
- // Read samples directly WITHOUT sync correction
- var toRead = Math.Min(buffer.Length, _count);
- ReadSamplesFromBuffer(buffer.Slice(0, toRead));
-
- _count -= toRead;
- _totalRead += toRead;
- ConsumeSegments(toRead);
-
- // Update sync error tracking (but don't apply correction - caller does that)
- if (_playbackStarted && toRead > 0)
- {
- _samplesReadSinceStart += toRead;
- _samplesOutputSinceStart += toRead;
-
- CalculateSyncError(currentLocalTime);
- // NOTE: We do NOT call UpdateCorrectionRate() here.
- // The caller is responsible for correction via ISyncCorrectionProvider.
-
- // Check re-anchor threshold
- var elapsedSinceStart = (long)(_samplesOutputSinceStart * _microsecondsPerSample);
- if (elapsedSinceStart >= _syncOptions.StartupGracePeriodMicroseconds
- && Math.Abs(_currentSyncErrorMicroseconds) > _syncOptions.ReanchorThresholdMicroseconds)
- {
- if (currentLocalTime - _lastReanchorTimeMicroseconds >= _syncOptions.ReanchorCooldownMicroseconds)
- {
- _lastReanchorTimeMicroseconds = currentLocalTime;
- _needsReanchor = true;
- }
- else if (currentLocalTime - _lastReanchorCooldownLogTime >= UnderrunLogIntervalMicroseconds)
- {
- _lastReanchorCooldownLogTime = currentLocalTime;
- _logger.LogWarning(
- "[Correction] Reanchor suppressed by cooldown ({CooldownMs}ms remaining)",
- (_syncOptions.ReanchorCooldownMicroseconds - (currentLocalTime - _lastReanchorTimeMicroseconds)) / 1000);
- }
- }
- }
-
- // Fill remainder with silence if needed
- if (toRead < buffer.Length)
- {
- buffer.Slice(toRead).Fill(0f);
- }
-
- return toRead;
- }
- }
-
- ///
- ///
- ///
- /// Contract: Either OR
- /// should be non-zero, but not both simultaneously. Dropping and inserting in the same correction
- /// cycle is logically invalid - you either need to speed up (drop) or slow down (insert), not both.
- ///
- ///
- /// The enforces this by design - it only sets either
- /// or
- /// to a non-zero value, never both. However, if using a custom correction provider, ensure this
- /// invariant is maintained.
- ///
- ///
- public void NotifyExternalCorrection(int samplesDropped, int samplesInserted)
- {
- if (samplesDropped < 0)
- {
- throw new ArgumentOutOfRangeException(nameof(samplesDropped), samplesDropped,
- "Sample count must be non-negative.");
- }
-
- if (samplesInserted < 0)
- {
- throw new ArgumentOutOfRangeException(nameof(samplesInserted), samplesInserted,
- "Sample count must be non-negative.");
- }
-
- // Debug assertion: dropping and inserting simultaneously is logically invalid.
- // At runtime, we don't throw because SyncCorrectionCalculator already ensures
- // mutual exclusivity, and the tracking math still works (just unusual).
- System.Diagnostics.Debug.Assert(
- samplesDropped == 0 || samplesInserted == 0,
- $"NotifyExternalCorrection called with both dropped ({samplesDropped}) and inserted ({samplesInserted}) > 0. " +
- "This is logically invalid - correction should be either drop OR insert, not both.");
-
- lock (_lock)
- {
- // When dropping: we read MORE samples than we output
- // This advances the server cursor, making sync error smaller
- _samplesReadSinceStart += samplesDropped;
- _samplesDroppedForSync += samplesDropped;
-
- // When inserting: we output samples WITHOUT consuming from buffer
- // ReadRaw already added the full read count to _samplesReadSinceStart,
- // but inserted samples came from duplicating previous output, not from new input.
- // So we need to SUBTRACT them to reflect actual consumption from buffer.
- _samplesReadSinceStart -= samplesInserted;
- _samplesInsertedForSync += samplesInserted;
- }
- }
-
- ///
- public void NotifyReconnect()
- {
- lock (_lock)
- {
- // Reset EMA to prevent stale pre-disconnect values from polluting correction decisions
- // At α=0.1, the EMA takes ~100ms to reach 63% of a step change — without resetting,
- // old values would linger even after the stabilization period ends
- _smoothedSyncErrorMicroseconds = 0;
-
- _inReconnectStabilization = true;
- _reconnectStabilizationStartOutput = _samplesOutputSinceStart;
-
- // Reset internal correction state to neutral
- _dropEveryNFrames = 0;
- _insertEveryNFrames = 0;
- _framesSinceLastCorrection = 0;
- SetTargetPlaybackRate(1.0);
-
- _logger.LogInformation("[Correction] Reconnect stabilization started (suppressing corrections for {DurationMs}ms)",
- _syncOptions.ReconnectStabilizationMicroseconds / 1000);
- }
- }
-
- ///
- public void Clear()
- {
- lock (_lock)
- {
- _writePos = 0;
- _readPos = 0;
- _count = 0;
- _segments.Clear();
- _playbackStarted = false;
- _nextExpectedPlaybackTime = 0;
-
- // Reset scheduled start state
- _scheduledStartLocalTime = 0;
- _waitingForScheduledStart = false;
-
- // Reset sync error tracking (CLI-style: reset EVERYTHING on clear)
- // This matches Python CLI's clear() behavior for track changes
- _playbackStartLocalTime = 0;
- _lastElapsedMicroseconds = 0;
- _samplesReadSinceStart = 0;
- _samplesOutputSinceStart = 0;
- _currentSyncErrorMicroseconds = 0;
- _smoothedSyncErrorMicroseconds = 0;
-
- // Reset sync correction state
- _dropEveryNFrames = 0;
- _insertEveryNFrames = 0;
- _framesSinceLastCorrection = 0;
- _needsReanchor = false;
- Interlocked.Exchange(ref _reanchorEventPending, 0);
- _lastOutputFrame = null;
- TargetPlaybackRate = 1.0;
- // Note: Don't reset _samplesDroppedForSync/_samplesInsertedForSync - these are cumulative stats
- // Note: Don't reset _lastReanchorTimeMicroseconds - reanchor itself calls Clear(),
- // so resetting the cooldown here would defeat its purpose (matches Android/Python CLI)
-
- // Reset reconnect stabilization state
- _inReconnectStabilization = false;
-
- // Reset correction mode transition tracking (avoids stale logging after clear)
- _previousCorrectionMode = SyncCorrectionMode.None;
- _correctionStartTimeUs = 0;
- _droppedAtSessionStart = 0;
- _insertedAtSessionStart = 0;
- }
- }
-
- ///
- /// Resets sync error tracking without clearing buffer content.
- /// Use this after audio device switches to prevent timing discontinuities
- /// from triggering false sync corrections.
- ///
- ///
- /// Unlike , this preserves buffered audio and only resets
- /// the timing state. The next audio callback will re-anchor timing from scratch.
- ///
- public void ResetSyncTracking()
- {
- lock (_lock)
- {
- // Signal that playback needs to re-establish its timing anchor
- // on the next Read() call, but keep buffered audio
- _playbackStarted = false;
-
- // Reset scheduled start state (will re-capture from next segment)
- _scheduledStartLocalTime = 0;
- _waitingForScheduledStart = false;
-
- // Reset sync error tracking
- _playbackStartLocalTime = 0;
- _lastElapsedMicroseconds = 0;
- _samplesReadSinceStart = 0;
- _samplesOutputSinceStart = 0;
- _currentSyncErrorMicroseconds = 0;
- _smoothedSyncErrorMicroseconds = 0;
-
- // Reset sync correction state
- _dropEveryNFrames = 0;
- _insertEveryNFrames = 0;
- _framesSinceLastCorrection = 0;
- _needsReanchor = false;
- Interlocked.Exchange(ref _reanchorEventPending, 0);
- _lastOutputFrame = null;
- TargetPlaybackRate = 1.0;
-
- // Reset correction mode transition tracking (avoids stale logging after reset)
- _previousCorrectionMode = SyncCorrectionMode.None;
- _correctionStartTimeUs = 0;
- _droppedAtSessionStart = 0;
- _insertedAtSessionStart = 0;
- }
- }
-
- ///
- public AudioBufferStats GetStats()
- {
- lock (_lock)
- {
- // Determine current correction mode based on active correction method
- SyncCorrectionMode correctionMode;
- if (_dropEveryNFrames > 0)
- correctionMode = SyncCorrectionMode.Dropping;
- else if (_insertEveryNFrames > 0)
- correctionMode = SyncCorrectionMode.Inserting;
- else if (Math.Abs(TargetPlaybackRate - 1.0) > 0.0001)
- correctionMode = SyncCorrectionMode.Resampling;
- else
- correctionMode = SyncCorrectionMode.None;
-
- return new AudioBufferStats
- {
- BufferedMs = _count / (double)_samplesPerMs,
- TargetMs = TargetBufferMilliseconds,
- UnderrunCount = _underrunCount,
- OverrunCount = _overrunCount,
- DroppedSamples = _droppedSamples,
- TotalSamplesWritten = _totalWritten,
- TotalSamplesRead = _totalRead,
- SyncErrorMicroseconds = _currentSyncErrorMicroseconds,
- SmoothedSyncErrorMicroseconds = _smoothedSyncErrorMicroseconds,
- IsPlaybackActive = _playbackStarted,
- SamplesDroppedForSync = _samplesDroppedForSync,
- SamplesInsertedForSync = _samplesInsertedForSync,
- CurrentCorrectionMode = correctionMode,
- TargetPlaybackRate = TargetPlaybackRate,
- SamplesReadSinceStart = _samplesReadSinceStart,
- SamplesOutputSinceStart = _samplesOutputSinceStart,
- ElapsedSinceStartMs = _lastElapsedMicroseconds / 1000.0,
- TimingSourceName = TimingSourceName,
- };
- }
- }
-
- ///
- public void Dispose()
- {
- if (_disposed)
- {
- return;
- }
-
- _disposed = true;
-
- lock (_lock)
- {
- _buffer = Array.Empty();
- _segments.Clear();
- }
- }
-
- ///
- /// Writes samples to the circular buffer.
- /// Must be called under lock.
- ///
- private void WriteSamplesToBuffer(ReadOnlySpan samples)
- {
- var written = 0;
- while (written < samples.Length)
- {
- var chunkSize = Math.Min(samples.Length - written, _buffer.Length - _writePos);
- samples.Slice(written, chunkSize).CopyTo(_buffer.AsSpan(_writePos, chunkSize));
- _writePos = (_writePos + chunkSize) % _buffer.Length;
- written += chunkSize;
- }
- }
-
- ///
- /// Reads samples from the circular buffer.
- /// Must be called under lock.
- ///
- private int ReadSamplesFromBuffer(Span buffer)
- {
- var read = 0;
- while (read < buffer.Length && read < _count)
- {
- var chunkSize = Math.Min(buffer.Length - read, _buffer.Length - _readPos);
- chunkSize = Math.Min(chunkSize, _count - read);
- _buffer.AsSpan(_readPos, chunkSize).CopyTo(buffer.Slice(read, chunkSize));
- _readPos = (_readPos + chunkSize) % _buffer.Length;
- read += chunkSize;
- }
-
- return read;
- }
-
- ///
- /// Peeks samples from the circular buffer without advancing read position.
- /// Must be called under lock.
- ///
- /// Buffer to copy samples into.
- /// Number of samples to peek.
- /// Number of samples actually peeked.
- private int PeekSamplesFromBuffer(Span destination, int count)
- {
- return PeekSamplesFromBufferAtOffset(destination, count, 0);
- }
-
- ///
- /// Peeks samples from the circular buffer at a specified offset without advancing read position.
- /// Must be called under lock.
- ///
- /// Buffer to copy samples into.
- /// Number of samples to peek.
- /// Offset from current read position (in samples).
- /// Number of samples actually peeked.
- private int PeekSamplesFromBufferAtOffset(Span destination, int count, int offset)
- {
- // Check if offset is within available data
- if (offset >= _count)
- {
- return 0;
- }
-
- var availableAfterOffset = _count - offset;
- var toPeek = Math.Min(count, availableAfterOffset);
- var peeked = 0;
- var tempReadPos = (_readPos + offset) % _buffer.Length;
-
- while (peeked < toPeek && peeked < destination.Length)
- {
- var chunkSize = Math.Min(toPeek - peeked, _buffer.Length - tempReadPos);
- chunkSize = Math.Min(chunkSize, destination.Length - peeked);
- _buffer.AsSpan(tempReadPos, chunkSize).CopyTo(destination.Slice(peeked, chunkSize));
- tempReadPos = (tempReadPos + chunkSize) % _buffer.Length;
- peeked += chunkSize;
- }
-
- return peeked;
- }
-
- ///
- /// Skips forward through the buffer to discard stale audio whose playback time has already passed.
- /// Called when playback starts and the buffer contains audio with timestamps in the past.
- /// Without this, a large buffer holding a server burst would start playing from audio that's
- /// 20+ seconds old, causing massive sync offset vs other players (even though sync error reads 0).
- /// Must be called under lock.
- ///
- /// Current local time in microseconds.
- /// Number of samples skipped.
- private int SkipStaleAudio(long currentLocalTime)
- {
- var totalSkipped = 0;
-
- // Skip segments whose playback time is in the past, but keep at least
- // the target buffer depth worth of audio so we have something to play
- while (_segments.Count > 1 && _count > _samplesPerMs * (int)TargetBufferMilliseconds)
- {
- var segment = _segments.Peek();
-
- // Stop skipping once we reach audio that's near-current or in the future.
- // Use the grace window as tolerance (default 10ms).
- if (segment.LocalPlaybackTime >= currentLocalTime - _syncOptions.ScheduledStartGraceWindowMicroseconds)
- break;
-
- // This segment is stale — skip it
- var toSkip = Math.Min(segment.SampleCount, _count);
- _readPos = (_readPos + toSkip) % _buffer.Length;
- _count -= toSkip;
- _droppedSamples += toSkip;
- totalSkipped += toSkip;
- ConsumeSegments(toSkip);
- }
-
- if (totalSkipped > 0)
- {
- var skippedMs = totalSkipped / (double)_samplesPerMs;
- _logger.LogInformation(
- "[Buffer] Skipped {SkippedMs:F0}ms of stale audio on playback start (buffer had audio from the past)",
- skippedMs);
-
- // Re-anchor scheduled start to the new first segment
- if (_segments.Count > 0)
- {
- var newFirst = _segments.Peek();
- _scheduledStartLocalTime = newFirst.LocalPlaybackTime;
- _nextExpectedPlaybackTime = newFirst.LocalPlaybackTime;
- }
- }
-
- return totalSkipped;
- }
-
- ///
- /// Drops the oldest samples to make room for new data.
- /// Must be called under lock.
- ///
- private void DropOldestSamples(int toDrop)
- {
- var dropped = 0;
- while (dropped < toDrop && _count > 0)
- {
- var chunkSize = Math.Min(toDrop - dropped, _buffer.Length - _readPos);
- chunkSize = Math.Min(chunkSize, _count);
- _readPos = (_readPos + chunkSize) % _buffer.Length;
- _count -= chunkSize;
- dropped += chunkSize;
- }
-
- _droppedSamples += dropped;
-
- // Also update segment tracking
- ConsumeSegments(dropped);
- }
-
- ///
- /// Consumes segment tracking entries for read/dropped samples.
- /// Must be called under lock.
- ///
- private void ConsumeSegments(int samplesConsumed)
- {
- var remaining = samplesConsumed;
- while (remaining > 0 && _segments.Count > 0)
- {
- var segment = _segments.Peek();
- if (segment.SampleCount <= remaining)
- {
- remaining -= segment.SampleCount;
- _segments.Dequeue();
- }
- else
- {
- // Partial segment - update remaining count
- _segments.Dequeue();
- _segments.Enqueue(segment with { SampleCount = segment.SampleCount - remaining });
- break;
- }
- }
- }
-
- ///
- /// Calculates the current sync error using CLI-style server cursor tracking.
- /// Must be called under lock.
- ///
- ///
- ///
- /// CLI approach: sync_error = expected_server_position - actual_server_cursor
- ///
- ///
- /// Expected position = first server timestamp + elapsed wall clock time.
- /// Actual cursor = server timestamp we've READ up to (advanced by samplesRead).
- ///
- ///
- /// When DROPPING (read 2, output 1):
- /// - Cursor advances by 2 frames worth of time
- /// - Expected advances by 1 frame worth of time (wall clock)
- /// - Error shrinks! (cursor catches up to expected) ✓
- ///
- ///
- /// When INSERTING (read 0, output 1):
- /// - Cursor stays still
- /// - Expected advances by 1 frame worth of time (wall clock)
- /// - Error grows toward 0! (expected catches up to cursor) ✓
- ///
- ///
- private void CalculateSyncError(long currentLocalTime)
- {
- // Elapsed wall-clock time since playback started
- var elapsedTimeMicroseconds = currentLocalTime - _playbackStartLocalTime;
- _lastElapsedMicroseconds = elapsedTimeMicroseconds;
-
- // How much server time have we actually READ (consumed) from the buffer?
- var samplesReadTimeMicroseconds = (long)(_samplesReadSinceStart * _microsecondsPerSample);
-
- // Sync error = elapsed - samples_read_time
- //
- // Positive = we haven't read enough (behind) = need to DROP (read faster)
- // Negative = we've read too much (ahead) = need to INSERT (slow down)
- //
- // Note: For push-model backends (ALSA), the static buffer pre-fill time is handled
- // by backdating _playbackStartLocalTime when playback starts. This keeps the sync
- // error formula clean and focused on drift/fluctuations only.
- _currentSyncErrorMicroseconds = elapsedTimeMicroseconds - samplesReadTimeMicroseconds;
-
- // Apply EMA smoothing to filter measurement jitter.
- // This prevents rapid correction changes from noisy measurements while still
- // tracking the underlying trend. The smoothed value is used for correction decisions.
- //
- // Special case: if smoothed error is 0 (just started or after reset), initialize
- // it to the current raw error to avoid slow ramp-up that causes rate oscillation.
- if (_smoothedSyncErrorMicroseconds == 0 && _currentSyncErrorMicroseconds != 0)
- {
- _smoothedSyncErrorMicroseconds = _currentSyncErrorMicroseconds;
- }
- else
- {
- _smoothedSyncErrorMicroseconds = SyncErrorSmoothingAlpha * _currentSyncErrorMicroseconds
- + (1 - SyncErrorSmoothingAlpha) * _smoothedSyncErrorMicroseconds;
- }
- }
-
- ///
- /// Updates the correction rate based on current sync error.
- /// Must be called under lock.
- ///
- ///
- ///
- /// Implements a tiered correction strategy:
- ///
- ///
- /// - Error < 1ms (deadband): No correction, playback rate = 1.0
- /// - Error 1-15ms: Proportional rate adjustment (error / targetSeconds), clamped to max
- /// - Error > 15ms: Frame drop/insert for faster correction
- ///
- ///
- /// Uses EMA-smoothed sync error to prevent jittery corrections from measurement noise.
- /// Proportional correction (matching Python CLI) prevents overshoot by adjusting rate
- /// based on error magnitude rather than using fixed rate steps.
- ///
- ///
- private void UpdateCorrectionRate()
- {
- // Skip correction during startup grace period to allow playback to stabilize
- // This prevents over-correction due to initial timing jitter
- var elapsedSinceStart = (long)(_samplesOutputSinceStart * _microsecondsPerSample);
- if (elapsedSinceStart < _syncOptions.StartupGracePeriodMicroseconds)
- {
- SetTargetPlaybackRate(1.0);
- _dropEveryNFrames = 0;
- _insertEveryNFrames = 0;
- return;
- }
-
- // Skip correction during reconnect stabilization (Kalman filter re-converging)
- if (_inReconnectStabilization)
- {
- var samplesSinceReconnect = _samplesOutputSinceStart - _reconnectStabilizationStartOutput;
- var elapsedSinceReconnect = (long)(samplesSinceReconnect * _microsecondsPerSample);
- if (elapsedSinceReconnect >= _syncOptions.ReconnectStabilizationMicroseconds)
- {
- _inReconnectStabilization = false;
- _logger.LogInformation("[Correction] Reconnect stabilization ended, resuming corrections");
- }
- else
- {
- SetTargetPlaybackRate(1.0);
- _dropEveryNFrames = 0;
- _insertEveryNFrames = 0;
- return;
- }
- }
-
- // Use smoothed error for correction decisions (filters measurement jitter)
- var absError = Math.Abs(_smoothedSyncErrorMicroseconds);
-
- // Thresholds for correction tiers
- const long DeadbandThreshold = 1_000; // 1ms - no correction below this
- const long ResamplingThreshold = 15_000; // 15ms - above this use drop/insert
-
- // Tier 1: Deadband - error is small enough to ignore
- if (absError < DeadbandThreshold)
- {
- LogCorrectionModeTransition(SyncCorrectionMode.None);
- SetTargetPlaybackRate(1.0);
- _dropEveryNFrames = 0;
- _insertEveryNFrames = 0;
- return;
- }
-
- // Tier 2: Proportional rate correction (1-15ms errors)
- // Rate = 1.0 + (error_µs / target_seconds / 1,000,000)
- // This calculates the rate needed to eliminate the error over the target time.
- // Example: 10ms error with 3s target → rate = 1.00333 (0.33% faster)
- if (absError < ResamplingThreshold)
- {
- // Log transition from drop/insert mode back to resampling (effectively None for drop/insert)
- LogCorrectionModeTransition(SyncCorrectionMode.Resampling);
-
- // Calculate proportional correction (matching Python CLI approach)
- var correctionFactor = _smoothedSyncErrorMicroseconds
- / _syncOptions.CorrectionTargetSeconds
- / 1_000_000.0;
-
- // Clamp to configured maximum speed adjustment
- correctionFactor = Math.Clamp(correctionFactor,
- -_syncOptions.MaxSpeedCorrection,
- _syncOptions.MaxSpeedCorrection);
-
- var newRate = 1.0 + correctionFactor;
- SetTargetPlaybackRate(newRate);
- _dropEveryNFrames = 0;
- _insertEveryNFrames = 0;
- return;
- }
-
- // Tier 3: Large errors (>15ms) - use frame drop/insert for faster correction
- // Reset playback rate to 1.0 since we're using discrete sample correction
- SetTargetPlaybackRate(1.0);
-
- // Calculate desired corrections per second to fix error within target time
- // Error in frames = error_us * sample_rate / 1,000,000
- var framesError = absError * _sampleRate / 1_000_000.0;
- var desiredCorrectionsPerSec = framesError / _syncOptions.CorrectionTargetSeconds;
-
- // Calculate frames per second
- var framesPerSecond = (double)_sampleRate;
-
- // Limit correction rate to max speed adjustment
- var maxCorrectionsPerSec = framesPerSecond * _syncOptions.MaxSpeedCorrection;
- var actualCorrectionsPerSec = Math.Min(desiredCorrectionsPerSec, maxCorrectionsPerSec);
-
- // Calculate how often to apply a correction (every N frames)
- var correctionInterval = actualCorrectionsPerSec > 0
- ? (int)(framesPerSecond / actualCorrectionsPerSec)
- : 0;
-
- // Minimum interval to prevent too-aggressive correction
- correctionInterval = Math.Max(correctionInterval, _channels * 10);
-
- if (_smoothedSyncErrorMicroseconds > 0)
- {
- // Playing too slow - need to drop frames to catch up
- _dropEveryNFrames = correctionInterval;
- _insertEveryNFrames = 0;
- LogCorrectionModeTransition(SyncCorrectionMode.Dropping);
- }
- else
- {
- // Playing too fast - need to insert frames to slow down
- _dropEveryNFrames = 0;
- _insertEveryNFrames = correctionInterval;
- LogCorrectionModeTransition(SyncCorrectionMode.Inserting);
- }
- }
-
- ///
- /// Logs sync correction mode transitions for debugging.
- /// Must be called under lock.
- ///
- ///
- /// This method tracks when correction mode changes (e.g., None -> Dropping, Dropping -> None)
- /// and logs the transition with cumulative counts and duration information.
- /// This helps diagnose what triggers large frame drop/insert corrections.
- ///
- /// The new correction mode being entered.
- private void LogCorrectionModeTransition(SyncCorrectionMode newMode)
- {
- if (newMode == _previousCorrectionMode)
- {
- return; // No transition
- }
-
- var currentTimeUs = _playbackStartLocalTime > 0
- ? _lastElapsedMicroseconds + _playbackStartLocalTime
- : 0;
-
- // Log the END of the previous correction session (if any)
- if (_previousCorrectionMode == SyncCorrectionMode.Dropping)
- {
- var sessionDropped = _samplesDroppedForSync - _droppedAtSessionStart;
- var sessionDurationMs = _correctionStartTimeUs > 0
- ? (currentTimeUs - _correctionStartTimeUs) / 1000.0
- : 0;
-
- _logger.LogInformation(
- "[Correction] Ended: DROPPING complete (dropped={DroppedSession} session, {DroppedTotal} total, duration={DurationMs:F0}ms, timing={TimingSource})",
- sessionDropped,
- _samplesDroppedForSync,
- sessionDurationMs,
- TimingSourceName ?? "unknown");
- }
- else if (_previousCorrectionMode == SyncCorrectionMode.Inserting)
- {
- var sessionInserted = _samplesInsertedForSync - _insertedAtSessionStart;
- var sessionDurationMs = _correctionStartTimeUs > 0
- ? (currentTimeUs - _correctionStartTimeUs) / 1000.0
- : 0;
-
- _logger.LogInformation(
- "[Correction] Ended: INSERTING complete (inserted={InsertedSession} session, {InsertedTotal} total, duration={DurationMs:F0}ms, timing={TimingSource})",
- sessionInserted,
- _samplesInsertedForSync,
- sessionDurationMs,
- TimingSourceName ?? "unknown");
- }
-
- // Log the START of the new correction session (if not None)
- if (newMode == SyncCorrectionMode.Dropping)
- {
- _correctionStartTimeUs = currentTimeUs;
- _droppedAtSessionStart = _samplesDroppedForSync;
-
- _logger.LogInformation(
- "[Correction] Started: DROPPING (syncError={SyncErrorMs:+0.00;-0.00}ms, smoothed={SmoothedMs:+0.00;-0.00}ms, " +
- "dropEveryN={DropEveryN}, elapsed={ElapsedMs:F0}ms, timing={TimingSource})",
- _currentSyncErrorMicroseconds / 1000.0,
- _smoothedSyncErrorMicroseconds / 1000.0,
- _dropEveryNFrames,
- _lastElapsedMicroseconds / 1000.0,
- TimingSourceName ?? "unknown");
- }
- else if (newMode == SyncCorrectionMode.Inserting)
- {
- _correctionStartTimeUs = currentTimeUs;
- _insertedAtSessionStart = _samplesInsertedForSync;
-
- _logger.LogInformation(
- "[Correction] Started: INSERTING (syncError={SyncErrorMs:+0.00;-0.00}ms, smoothed={SmoothedMs:+0.00;-0.00}ms, " +
- "insertEveryN={InsertEveryN}, elapsed={ElapsedMs:F0}ms, timing={TimingSource})",
- _currentSyncErrorMicroseconds / 1000.0,
- _smoothedSyncErrorMicroseconds / 1000.0,
- _insertEveryNFrames,
- _lastElapsedMicroseconds / 1000.0,
- TimingSourceName ?? "unknown");
- }
-
- _previousCorrectionMode = newMode;
- }
-
- ///
- /// Sets the target playback rate and raises the change event if different.
- /// Must be called under lock.
- ///
- ///
- ///
- /// Thread Safety Note: This event is intentionally fired while holding the buffer lock.
- ///
- ///
- /// Deadlock analysis (why this is safe):
- ///
- /// - This event is marked [Obsolete] - new code uses ISyncCorrectionProvider.CorrectionChanged instead
- /// - No active subscribers exist in the current codebase
- /// - Even when subscribers existed, they only stored the value (no callback into buffer)
- /// - Firing outside the lock would require Task.Run allocation on every rate change (~100Hz)
- ///
- ///
- ///
- /// If you add a subscriber, ensure it does NOT call any TimedAudioBuffer methods or you will deadlock.
- ///
- ///
- private void SetTargetPlaybackRate(double rate)
- {
- if (Math.Abs(TargetPlaybackRate - rate) > 0.0001)
- {
- TargetPlaybackRate = rate;
- // Fire event inline while holding lock. This is safe because:
- // 1. The event is [Obsolete] with no active subscribers
- // 2. Any future subscriber must be lightweight (just store value, no callbacks)
- // 3. Firing via Task.Run would add allocation overhead on every rate change
- TargetPlaybackRateChanged?.Invoke(rate);
- }
- }
-
- ///
- /// Reads samples with sync correction applied (drop or insert frames as needed).
- /// Must be called under lock.
- ///
- /// Output buffer to fill.
- /// Number of samples to read from internal buffer.
- /// Tuple of (samples consumed from buffer, samples written to output).
- ///
- /// Uses the Python CLI approach for smoother corrections:
- /// - Drop: Read TWO frames from input, output the LAST frame (skip one input)
- /// - Insert: Output the last frame AGAIN without reading from input
- /// This maintains audio continuity by always using recently-played samples.
- ///
- private (int ActualRead, int OutputCount) ReadWithSyncCorrection(Span buffer, int toRead)
- {
- var frameSamples = _channels; // One frame = all channels for one time point
-
- // Initialize last output frame if needed
- _lastOutputFrame ??= new float[frameSamples];
-
- // If no correction needed, use optimized bulk read
- if (_dropEveryNFrames == 0 && _insertEveryNFrames == 0)
- {
- var read = ReadSamplesFromBuffer(buffer.Slice(0, toRead));
-
- // Save last frame for potential future corrections
- if (read >= frameSamples)
- {
- buffer.Slice(read - frameSamples, frameSamples).CopyTo(_lastOutputFrame);
- }
-
- return (read, read);
- }
-
- // Process frame by frame, applying corrections (Python CLI approach)
- var outputPos = 0;
- var samplesConsumed = 0;
- Span tempFrame = stackalloc float[frameSamples];
-
- // Continue until output buffer is full (not until we've consumed toRead)
- // When dropping, we consume MORE from input to fill output with real audio.
- // Previously, the loop exited when samplesConsumed >= toRead, leaving the
- // output buffer partially filled with silence - which doesn't speed up playback!
- while (outputPos < buffer.Length)
- {
- // Check if we have a full frame to read from internal buffer.
- // Use _count - samplesConsumed to check ACTUAL remaining, not planned toRead.
- var remainingInBuffer = _count - samplesConsumed;
- if (remainingInBuffer < frameSamples)
- {
- break; // Underrun - not enough audio in internal buffer
- }
-
- // Check remaining output space
- if (buffer.Length - outputPos < frameSamples)
- {
- break;
- }
-
- _framesSinceLastCorrection++;
-
- // Check if we should DROP a frame (read two, output 3-point interpolated blend)
- if (_dropEveryNFrames > 0 && _framesSinceLastCorrection >= _dropEveryNFrames)
- {
- _framesSinceLastCorrection = 0;
-
- // Need at least 2 frames for interpolated drop
- if (_count - samplesConsumed >= frameSamples * 2)
- {
- // Read frame A (the one before the drop point)
- ReadSamplesFromBuffer(tempFrame);
- samplesConsumed += frameSamples;
-
- // Read frame B (the one we're skipping over)
- Span droppedFrame = stackalloc float[frameSamples];
- ReadSamplesFromBuffer(droppedFrame);
- samplesConsumed += frameSamples;
-
- // 3-point weighted interpolation: lastOutput + frameA + frameB
- // Weights: 0.25 (continuity from previous) + 0.5 (primary) + 0.25 (dropped)
- // This creates smoother transitions than simple 2-point averaging
- var outputSpan = buffer.Slice(outputPos, frameSamples);
- for (int i = 0; i < frameSamples; i++)
- {
- outputSpan[i] = (0.25f * _lastOutputFrame[i]) +
- (0.5f * tempFrame[i]) +
- (0.25f * droppedFrame[i]);
- }
-
- // Save interpolated frame as last output for continuity
- outputSpan.CopyTo(_lastOutputFrame);
- outputPos += frameSamples;
- _samplesDroppedForSync += frameSamples;
- continue;
- }
- else if (_count - samplesConsumed >= frameSamples)
- {
- // Fallback: only 1 frame available, output it directly
- ReadSamplesFromBuffer(tempFrame);
- samplesConsumed += frameSamples;
- tempFrame.CopyTo(buffer.Slice(outputPos, frameSamples));
- tempFrame.CopyTo(_lastOutputFrame);
- outputPos += frameSamples;
- continue;
- }
- }
-
- // Check if we should INSERT a frame (output 3-point interpolated without consuming)
- if (_insertEveryNFrames > 0 && _framesSinceLastCorrection >= _insertEveryNFrames)
- {
- _framesSinceLastCorrection = 0;
-
- var outputSpan = buffer.Slice(outputPos, frameSamples);
-
- // Try to peek at next TWO frames for 3-point interpolation (without consuming)
- if (_count - samplesConsumed >= frameSamples * 2)
- {
- // Peek at next frame (position 0 in buffer)
- Span nextFrame = stackalloc float[frameSamples];
- PeekSamplesFromBuffer(nextFrame, frameSamples);
-
- // Peek at frame after next (position 1 in buffer) - need offset peek
- Span frameAfterNext = stackalloc float[frameSamples];
- PeekSamplesFromBufferAtOffset(frameAfterNext, frameSamples, frameSamples);
-
- // 3-point weighted interpolation: lastOutput + nextFrame + frameAfterNext
- // Weights: 0.25 (previous) + 0.5 (next) + 0.25 (future) for curve smoothing
- for (int i = 0; i < frameSamples; i++)
- {
- outputSpan[i] = (0.25f * _lastOutputFrame[i]) +
- (0.5f * nextFrame[i]) +
- (0.25f * frameAfterNext[i]);
- }
-
- // Save interpolated frame for continuity
- outputSpan.CopyTo(_lastOutputFrame);
- }
- else if (_count - samplesConsumed >= frameSamples)
- {
- // Fallback to 2-point: only 1 frame available
- Span nextFrame = stackalloc float[frameSamples];
- PeekSamplesFromBuffer(nextFrame, frameSamples);
-
- for (int i = 0; i < frameSamples; i++)
- {
- outputSpan[i] = (_lastOutputFrame[i] + nextFrame[i]) * 0.5f;
- }
-
- outputSpan.CopyTo(_lastOutputFrame);
- }
- else
- {
- // Fallback: no next frame available, duplicate last
- _lastOutputFrame.AsSpan().CopyTo(outputSpan);
- }
-
- outputPos += frameSamples;
- _samplesInsertedForSync += frameSamples;
- // Don't increment samplesConsumed - we didn't consume from buffer
- continue;
- }
-
- // Normal frame: read from buffer and output
- var frameSpan = buffer.Slice(outputPos, frameSamples);
- ReadSamplesFromBuffer(frameSpan);
- samplesConsumed += frameSamples;
-
- // Save as last output frame for future corrections
- frameSpan.CopyTo(_lastOutputFrame);
- outputPos += frameSamples;
- }
-
- return (samplesConsumed, outputPos);
- }
-
- ///
- /// Logs underrun events with rate limiting to prevent log spam.
- /// Must be called under lock.
- ///
- ///
- /// During severe underruns, this method can be called many times per second
- /// (once per audio callback, typically every ~10ms). Rate limiting ensures
- /// we log at most once per second while still capturing the total count.
- ///
- private void LogUnderrunIfNeeded(long currentLocalTime)
- {
- // Check if enough time has passed since last log
- if (currentLocalTime - _lastUnderrunLogTime < UnderrunLogIntervalMicroseconds)
- {
- return;
- }
-
- // Log the accumulated underruns
- _logger.LogWarning(
- "[Buffer] Underrun: {Count} events in last {IntervalMs}ms (total: {TotalCount}). " +
- "Buffer empty, outputting silence. Check network/decoding performance.",
- _underrunsSinceLastLog,
- (currentLocalTime - _lastUnderrunLogTime) / 1000,
- _underrunCount);
-
- // Reset rate limit state
- _lastUnderrunLogTime = currentLocalTime;
- _underrunsSinceLastLog = 0;
- }
-
- ///
- /// Represents a segment of samples with its target playback time.
- ///
- /// Local time (microseconds) when this segment should play.
- /// Number of interleaved samples in this segment.
- private readonly record struct TimestampedSegment(long LocalPlaybackTime, int SampleCount);
-}
diff --git a/src/Sendspin.SDK/Client/ClientCapabilities.cs b/src/Sendspin.SDK/Client/ClientCapabilities.cs
deleted file mode 100644
index 5f14e58..0000000
--- a/src/Sendspin.SDK/Client/ClientCapabilities.cs
+++ /dev/null
@@ -1,89 +0,0 @@
-using Sendspin.SDK.Models;
-
-namespace Sendspin.SDK.Client;
-
-///
-/// Defines the capabilities this client advertises to the server.
-///
-public sealed class ClientCapabilities
-{
- ///
- /// Unique client identifier (persisted across sessions).
- /// Format follows reference implementation: sendspin-windows-{hostname}
- ///
- public string ClientId { get; set; } = $"sendspin-windows-{Environment.MachineName.ToLowerInvariant()}";
-
- ///
- /// Human-readable client name.
- ///
- public string ClientName { get; set; } = Environment.MachineName;
-
- ///
- /// Roles this client supports, in priority order.
- /// Matches reference implementation: controller, player, metadata (no artwork for now).
- ///
- public List Roles { get; set; } = new()
- {
- "controller@v1",
- "player@v1",
- "metadata@v1",
- "artwork@v1"
- };
-
- ///
- /// Audio formats the client can decode.
- /// Order matters - server picks the first format it supports.
- ///
- public List AudioFormats { get; set; } = new()
- {
- new AudioFormat { Codec = "opus", SampleRate = 48000, Channels = 2, Bitrate = 256 },
- new AudioFormat { Codec = "pcm", SampleRate = 48000, Channels = 2, BitDepth = 16 },
- new AudioFormat { Codec = "flac", SampleRate = 48000, Channels = 2 }, // Last - server prefers earlier formats
- };
-
- ///
- /// Audio buffer capacity in compressed bytes. The server uses this to limit how much
- /// audio it sends ahead. Should be derived from your PCM buffer duration and the
- /// highest-bitrate codec you support. Default is 32MB (reference implementation fallback).
- ///
- public int BufferCapacity { get; set; } = 32_000_000;
-
- ///
- /// Preferred artwork formats.
- ///
- public List ArtworkFormats { get; set; } = new() { "jpeg", "png" };
-
- ///
- /// Maximum artwork dimension.
- ///
- public int ArtworkMaxSize { get; set; } = 512;
-
- ///
- /// Product name reported to the server (e.g., "Sendspin Windows Client", "My Custom Player").
- ///
- public string? ProductName { get; set; }
-
- ///
- /// Manufacturer name reported to the server (e.g., "Anthropic", "My Company").
- ///
- public string? Manufacturer { get; set; }
-
- ///
- /// Software version reported to the server.
- /// If null, will not be included in the device info.
- ///
- public string? SoftwareVersion { get; set; }
-
- ///
- /// Initial volume level (0-100) to report to the server after connection.
- /// This is sent in the initial client/state message after handshake.
- /// Default is 100 for backwards compatibility.
- ///
- public int InitialVolume { get; set; } = 100;
-
- ///
- /// Initial mute state to report to the server after connection.
- /// This is sent in the initial client/state message after handshake.
- ///
- public bool InitialMuted { get; set; }
-}
diff --git a/src/Sendspin.SDK/Client/ClientRoles.cs b/src/Sendspin.SDK/Client/ClientRoles.cs
deleted file mode 100644
index 9996542..0000000
--- a/src/Sendspin.SDK/Client/ClientRoles.cs
+++ /dev/null
@@ -1,63 +0,0 @@
-namespace Sendspin.SDK.Client;
-
-///
-/// Client role identifiers used in the Sendspin protocol.
-/// Roles define what capabilities a client has.
-///
-public static class ClientRoles
-{
- ///
- /// Player role - outputs synchronized audio.
- ///
- public const string Player = "player";
-
- ///
- /// Controller role - can control group playback (play, pause, volume, etc.).
- ///
- public const string Controller = "controller";
-
- ///
- /// Metadata role - receives track metadata updates.
- ///
- public const string Metadata = "metadata";
-
- ///
- /// Artwork role - receives album artwork.
- ///
- public const string Artwork = "artwork";
-
- ///
- /// Visualizer role - receives audio visualization data.
- ///
- public const string Visualizer = "visualizer";
-}
-
-///
-/// Commands that can be sent to control playback.
-///
-public enum PlayerCommand
-{
- Play,
- Pause,
- Stop,
- Next,
- Previous,
- Shuffle,
- Repeat
-}
-
-///
-/// Volume adjustment modes.
-///
-public enum VolumeMode
-{
- ///
- /// Set absolute volume level.
- ///
- Absolute,
-
- ///
- /// Adjust volume by delta.
- ///
- Relative
-}
diff --git a/src/Sendspin.SDK/Client/ConnectionMode.cs b/src/Sendspin.SDK/Client/ConnectionMode.cs
deleted file mode 100644
index 38dcd16..0000000
--- a/src/Sendspin.SDK/Client/ConnectionMode.cs
+++ /dev/null
@@ -1,25 +0,0 @@
-namespace Sendspin.SDK.Client;
-
-///
-/// Determines how the client establishes connections with Sendspin servers.
-///
-public enum ConnectionMode
-{
- ///
- /// Both discover servers and advertise as a player (default).
- /// Client-initiated connections take priority over server-initiated ones.
- ///
- Auto,
-
- ///
- /// Only advertise via mDNS and wait for servers to connect.
- /// Equivalent to the Python CLI's daemon mode.
- ///
- AdvertiseOnly,
-
- ///
- /// Only discover servers via mDNS and connect to them.
- /// Does not advertise or listen for incoming connections.
- ///
- DiscoverOnly
-}
diff --git a/src/Sendspin.SDK/Client/ISendSpinClient.cs b/src/Sendspin.SDK/Client/ISendSpinClient.cs
deleted file mode 100644
index 05f035c..0000000
--- a/src/Sendspin.SDK/Client/ISendSpinClient.cs
+++ /dev/null
@@ -1,128 +0,0 @@
-using Sendspin.SDK.Connection;
-using Sendspin.SDK.Models;
-using Sendspin.SDK.Synchronization;
-
-namespace Sendspin.SDK.Client;
-
-///
-/// Main client interface for interacting with a Sendspin server.
-///
-public interface ISendspinClient : IAsyncDisposable
-{
- ///
- /// Current connection state.
- ///
- ConnectionState ConnectionState { get; }
-
- ///
- /// Server ID after successful connection.
- ///
- string? ServerId { get; }
-
- ///
- /// Server name after successful connection.
- ///
- string? ServerName { get; }
-
- ///
- /// Current group state (volume/mute represent group averages for display).
- ///
- GroupState? CurrentGroup { get; }
-
- ///
- /// This player's own volume and mute state (applied to audio output).
- ///
- ///
- /// Unlike , which contains the group average,
- /// this represents THIS player's actual volume as set by server/command
- /// messages or local user input.
- ///
- PlayerState CurrentPlayerState { get; }
-
- ///
- /// Current clock synchronization status.
- ///
- ClockSyncStatus? ClockSyncStatus { get; }
-
- ///
- /// Whether the clock synchronizer has converged to a stable estimate.
- ///
- bool IsClockSynced { get; }
-
- ///
- /// Connects to a Sendspin server.
- ///
- Task ConnectAsync(Uri serverUri, CancellationToken cancellationToken = default);
-
- ///
- /// Disconnects from the server.
- ///
- Task DisconnectAsync(string reason = "user_request");
-
- ///
- /// Sends a playback command.
- ///
- Task SendCommandAsync(string command, Dictionary? parameters = null);
-
- ///
- /// Sets the volume level (0-100).
- ///
- Task SetVolumeAsync(int volume);
-
- ///
- /// Sends the current player state (volume, muted) to the server.
- /// This is used to report local state changes to Music Assistant.
- ///
- /// Current volume level (0-100).
- /// Current mute state.
- /// 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.
- /// Use this when audio sync parameters change and you want immediate effect.
- ///
- void ClearAudioBuffer();
-
- ///
- /// Event raised when connection state changes.
- ///
- event EventHandler? ConnectionStateChanged;
-
- ///
- /// Event raised when group state updates (playback, metadata, volume).
- ///
- event EventHandler? GroupStateChanged;
-
- ///
- /// Event raised when THIS player's volume or mute state changes.
- ///
- ///
- /// This event fires when server/command messages change the player's
- /// volume or mute state. Subscribe to this for audio-affecting changes.
- ///
- event EventHandler? PlayerStateChanged;
-
- ///
- /// Event raised when artwork is received.
- ///
- 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.
- ///
- event EventHandler? ClockSyncConverged;
-
- ///
- /// Event raised when a sync offset is applied from external calibration (e.g., GroupSync).
- /// The offset adjusts the static delay to compensate for speaker/room acoustics.
- ///
- event EventHandler? SyncOffsetApplied;
-}
diff --git a/src/Sendspin.SDK/Client/SendSpinClient.cs b/src/Sendspin.SDK/Client/SendSpinClient.cs
deleted file mode 100644
index 8d0a1cc..0000000
--- a/src/Sendspin.SDK/Client/SendSpinClient.cs
+++ /dev/null
@@ -1,1088 +0,0 @@
-using System.Collections.Concurrent;
-using Microsoft.Extensions.Logging;
-using Sendspin.SDK.Audio;
-using Sendspin.SDK.Connection;
-using Sendspin.SDK.Extensions;
-using Sendspin.SDK.Models;
-using Sendspin.SDK.Protocol;
-using Sendspin.SDK.Protocol.Messages;
-using Sendspin.SDK.Synchronization;
-
-namespace Sendspin.SDK.Client;
-
-///
-/// Main Sendspin client that orchestrates connection, handshake, and message handling.
-///
-public sealed class SendspinClientService : ISendspinClient
-{
- private readonly ILogger _logger;
- private readonly ISendspinConnection _connection;
- private readonly ClientCapabilities _capabilities;
- private readonly IClockSynchronizer _clockSynchronizer;
- private readonly IAudioPipeline? _audioPipeline;
-
- private TaskCompletionSource? _handshakeTcs;
- private GroupState? _currentGroup;
- private PlayerState _playerState;
- private CancellationTokenSource? _timeSyncCts;
- private bool _disposed;
-
- ///
- /// Queue for audio chunks that arrive before pipeline is ready.
- /// Prevents chunk loss during the ~50ms decoder/buffer initialization.
- ///
- private readonly ConcurrentQueue _earlyChunkQueue = new();
-
- ///
- /// Maximum chunks to queue before pipeline ready (~2 seconds of audio at typical rates).
- ///
- private const int MaxEarlyChunks = 100;
-
- #region Time Sync Configuration
-
- ///
- /// Number of time sync messages to send in a burst.
- /// The measurement with the smallest round-trip time is used for best accuracy.
- ///
- ///
- /// Sending multiple messages allows us to identify network jitter and select
- /// the cleanest measurement. A burst of 8 typically yields at least one
- /// measurement with minimal queuing delay.
- ///
- private const int BurstSize = 8;
-
- ///
- /// Interval in milliseconds between burst messages.
- /// Short enough for quick bursts, long enough to avoid packet queuing.
- ///
- private const int BurstIntervalMs = 50;
-
- #endregion
-
- private readonly object _burstLock = new();
- private readonly List<(long t1, long t2, long t3, long t4, double rtt)> _burstResults = new();
- private readonly HashSet _pendingBurstTimestamps = new();
-
- public ConnectionState ConnectionState => _connection.State;
- public string? ServerId { get; private set; }
- public string? ServerName { get; private set; }
-
- ///
- /// The connection reason provided by the server in the server/hello handshake.
- /// Typically "discovery" (server found us via mDNS) or "playback" (server needs us for active playback).
- /// Used for multi-server arbitration in the host service.
- ///
- public string? ConnectionReason { get; private set; }
-
- public GroupState? CurrentGroup => _currentGroup;
- public PlayerState CurrentPlayerState => _playerState;
- public ClockSyncStatus? ClockSyncStatus => _clockSynchronizer.GetStatus();
- public bool IsClockSynced => _clockSynchronizer.IsConverged;
-
- public event EventHandler? ConnectionStateChanged;
- public event EventHandler? GroupStateChanged;
- public event EventHandler? PlayerStateChanged;
- public event EventHandler? ArtworkReceived;
- public event EventHandler? ArtworkCleared;
- public event EventHandler? ClockSyncConverged;
- public event EventHandler? SyncOffsetApplied;
-
- public SendspinClientService(
- ILogger logger,
- ISendspinConnection connection,
- IClockSynchronizer? clockSynchronizer = null,
- ClientCapabilities? capabilities = null,
- IAudioPipeline? audioPipeline = null)
- {
- _logger = logger;
- _connection = connection;
- _clockSynchronizer = clockSynchronizer ?? new KalmanClockSynchronizer();
- _capabilities = capabilities ?? new ClientCapabilities();
- _audioPipeline = audioPipeline;
-
- // Initialize player state from capabilities
- _playerState = new PlayerState
- {
- Volume = Math.Clamp(_capabilities.InitialVolume, 0, 100),
- Muted = _capabilities.InitialMuted
- };
-
- // Subscribe to connection events
- _connection.StateChanged += OnConnectionStateChanged;
- _connection.TextMessageReceived += OnTextMessageReceived;
- _connection.BinaryMessageReceived += OnBinaryMessageReceived;
- }
-
- public async Task ConnectAsync(Uri serverUri, CancellationToken cancellationToken = default)
- {
- ObjectDisposedException.ThrowIf(_disposed, this);
- _logger.LogInformation("Connecting to {Uri}", serverUri);
-
- // Connect WebSocket
- await _connection.ConnectAsync(serverUri, cancellationToken);
-
- // Perform handshake (send client hello, wait for server hello)
- await SendHandshakeAsync(cancellationToken);
- }
-
- ///
- /// Sends the ClientHello message and waits for the ServerHello response.
- /// Used for both initial connection and reconnection handshakes.
- ///
- private async Task SendHandshakeAsync(CancellationToken cancellationToken = default)
- {
- _handshakeTcs = new TaskCompletionSource();
-
- var hello = CreateClientHelloMessage();
- var helloJson = MessageSerializer.Serialize(hello);
- _logger.LogInformation("Sending client/hello:\n{Json}", helloJson);
- await _connection.SendMessageAsync(hello, cancellationToken);
-
- // Wait for server hello with timeout
- using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
- using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
-
- try
- {
- var registration = linkedCts.Token.Register(() => _handshakeTcs.TrySetCanceled());
- var success = await _handshakeTcs.Task;
-
- if (success)
- {
- _logger.LogInformation("Handshake complete with server {ServerId} ({ServerName})", ServerId, ServerName);
- }
- }
- catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
- {
- _logger.LogError("Handshake timeout - no server/hello received");
- await _connection.DisconnectAsync("handshake_timeout");
- throw new TimeoutException("Server did not respond to handshake");
- }
- }
-
- ///
- /// Creates the ClientHello message from current capabilities.
- /// Extracted for reuse between initial connection and reconnection handshakes.
- ///
- private ClientHelloMessage CreateClientHelloMessage()
- {
- return ClientHelloMessage.Create(
- clientId: _capabilities.ClientId,
- name: _capabilities.ClientName,
- supportedRoles: _capabilities.Roles,
- playerSupport: new PlayerSupport
- {
- SupportedFormats = _capabilities.AudioFormats
- .Select(f => new AudioFormatSpec
- {
- Codec = f.Codec,
- Channels = f.Channels,
- SampleRate = f.SampleRate,
- BitDepth = f.BitDepth ?? 16,
- })
- .ToList(),
- BufferCapacity = _capabilities.BufferCapacity,
- SupportedCommands = new List { "volume", "mute" }
- },
- artworkSupport: new ArtworkSupport
- {
- Channels = new List
- {
- new ArtworkChannelSpec
- {
- Source = "album",
- Format = _capabilities.ArtworkFormats.FirstOrDefault() ?? "jpeg",
- MediaWidth = _capabilities.ArtworkMaxSize,
- MediaHeight = _capabilities.ArtworkMaxSize
- }
- }
- },
- deviceInfo: new DeviceInfo
- {
- ProductName = _capabilities.ProductName,
- Manufacturer = _capabilities.Manufacturer,
- SoftwareVersion = _capabilities.SoftwareVersion
- }
- );
- }
-
- ///
- /// Performs handshake after the connection layer has successfully reconnected the WebSocket.
- /// Called from OnConnectionStateChanged when entering Handshaking state during reconnection.
- ///
- ///
- /// Clock synchronizer is reset in HandleServerHello when the handshake completes,
- /// so we don't need to reset it here.
- ///
- private async Task PerformReconnectHandshakeAsync(CancellationToken cancellationToken = default)
- {
- _logger.LogInformation("WebSocket reconnected, performing handshake...");
-
- try
- {
- await SendHandshakeAsync(cancellationToken);
- }
- catch (TimeoutException)
- {
- _logger.LogWarning("Reconnect handshake timed out");
- }
- catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
- {
- _logger.LogDebug("Reconnect handshake cancelled");
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Reconnect handshake failed");
- await _connection.DisconnectAsync("handshake_failed");
- }
- }
-
- public async Task DisconnectAsync(string reason = "user_request")
- {
- if (_disposed) return;
-
- _logger.LogInformation("Disconnecting: {Reason}", reason);
-
- // Stop time sync loop
- StopTimeSyncLoop();
-
- await _connection.DisconnectAsync(reason);
-
- ServerId = null;
- ServerName = null;
- ConnectionReason = null;
- _currentGroup = null;
- }
-
- public async Task SendCommandAsync(string command, Dictionary? parameters = null)
- {
- // Extract volume and mute from parameters if present
- int? volume = null;
- bool? mute = null;
-
- if (parameters != null)
- {
- if (parameters.TryGetValue("volume", out var volObj) && volObj is int vol)
- {
- volume = vol;
- }
-
- if (parameters.TryGetValue("muted", out var muteObj) && muteObj is bool m)
- {
- mute = m;
- }
- }
-
- var message = ClientCommandMessage.Create(command, volume, mute);
-
- _logger.LogDebug("Sending command: {Command}", command);
- await _connection.SendMessageAsync(message);
- }
-
- public async Task SetVolumeAsync(int volume)
- {
- var clampedVolume = Math.Clamp(volume, 0, 100);
- var message = ClientCommandMessage.Create(Commands.Volume, volume: clampedVolume);
-
- _logger.LogDebug("Setting volume to {Volume}", clampedVolume);
- await _connection.SendMessageAsync(message);
- }
-
- ///
- 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, staticDelayMs);
-
- _logger.LogDebug("Sending player state: Volume={Volume}, Muted={Muted}, StaticDelay={StaticDelay}ms",
- clampedVolume, muted, staticDelayMs);
- await _connection.SendMessageAsync(stateMessage);
- }
-
- ///
- public void ClearAudioBuffer()
- {
- _logger.LogDebug("Clearing audio buffer for immediate sync parameter effect");
- _audioPipeline?.Clear();
- }
-
- private void OnConnectionStateChanged(object? sender, ConnectionStateChangedEventArgs e)
- {
- _logger.LogDebug("Connection state: {OldState} -> {NewState}", e.OldState, e.NewState);
-
- // Forward the event
- ConnectionStateChanged?.Invoke(this, e);
-
- // Stop time sync on any disconnection-related state to prevent
- // "WebSocket is not connected" spam from the time sync loop
- if (e.NewState is ConnectionState.Disconnected or ConnectionState.Reconnecting)
- {
- StopTimeSyncLoop();
- }
-
- // Clean up client state on full disconnection
- if (e.NewState == ConnectionState.Disconnected)
- {
- _handshakeTcs?.TrySetResult(false);
- ServerId = null;
- ServerName = null;
- ConnectionReason = null;
- }
-
- // Re-handshake when WebSocket reconnects successfully
- // Use e.OldState instead of a separate field to avoid race conditions
- if (e.NewState == ConnectionState.Handshaking && e.OldState == ConnectionState.Reconnecting)
- {
- PerformReconnectHandshakeAsync().SafeFireAndForget(_logger);
- }
- }
-
- private void OnTextMessageReceived(object? sender, string json)
- {
- try
- {
- var messageType = MessageSerializer.GetMessageType(json);
- _logger.LogTrace("Received: {Type}", messageType);
-
- switch (messageType)
- {
- case MessageTypes.ServerHello:
- HandleServerHello(json);
- break;
-
- case MessageTypes.ServerTime:
- HandleServerTime(json);
- break;
-
- case MessageTypes.GroupUpdate:
- HandleGroupUpdate(json);
- break;
-
- case MessageTypes.StreamStart:
- HandleStreamStartAsync(json).SafeFireAndForget(_logger);
- break;
-
- case MessageTypes.StreamEnd:
- HandleStreamEndAsync(json).SafeFireAndForget(_logger);
- break;
-
- case MessageTypes.StreamClear:
- HandleStreamClear(json);
- break;
-
- case MessageTypes.ServerState:
- HandleServerState(json);
- break;
-
- case MessageTypes.ServerCommand:
- HandleServerCommand(json);
- break;
-
- case MessageTypes.ClientSyncOffset:
- HandleSyncOffset(json);
- break;
-
- default:
- _logger.LogDebug("Unhandled message type: {Type}", messageType);
- break;
- }
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error processing message");
- }
- }
-
- private void HandleServerHello(string json)
- {
- var message = MessageSerializer.Deserialize(json);
- if (message is null)
- {
- _logger.LogWarning("Failed to deserialize server/hello");
- _handshakeTcs?.TrySetResult(false);
- return;
- }
-
- ServerId = message.ServerId;
- ServerName = message.Name;
- ConnectionReason = message.Payload.ConnectionReason;
-
- _logger.LogInformation("Server hello received: {ServerId} ({ServerName}), reason: {ConnectionReason}, roles: {Roles}",
- message.ServerId, message.Name, ConnectionReason ?? "none", string.Join(", ", message.ActiveRoles));
-
- // Mark connection as fully connected
- if (_connection is SendspinConnection conn)
- {
- conn.MarkConnected();
- }
- else if (_connection is IncomingConnection incoming)
- {
- incoming.MarkConnected();
- }
-
- // Reset clock synchronizer for new connection
- _clockSynchronizer.Reset();
-
- // Notify audio pipeline of reconnect to suppress sync corrections
- // while the Kalman filter re-converges (~2 seconds).
- // Safe to call even on initial connection: _audioPipeline is null before first stream/start,
- // and NotifyReconnect on null buffer/player is a no-op.
- _audioPipeline?.NotifyReconnect();
-
- // Send initial client state (required by protocol after server/hello)
- // This tells the server we're synchronized and ready
- SendInitialClientStateAsync().SafeFireAndForget(_logger);
-
- // Start time synchronization loop with adaptive intervals
- StartTimeSyncLoop();
-
- _handshakeTcs?.TrySetResult(true);
- }
-
- ///
- /// Sends the initial client/state message after handshake.
- /// Per the protocol, clients with player role must send their state immediately.
- /// Uses the current which was initialized from ClientCapabilities.
- ///
- private async Task SendInitialClientStateAsync()
- {
- try
- {
- // Send the current player state (initialized from capabilities)
- var stateMessage = ClientStateMessage.CreateSynchronized(
- volume: _playerState.Volume,
- muted: _playerState.Muted,
- staticDelayMs: _clockSynchronizer.StaticDelayMs);
- var stateJson = MessageSerializer.Serialize(stateMessage);
- _logger.LogInformation("Sending initial client/state:\n{Json}", stateJson);
- await _connection.SendMessageAsync(stateMessage);
-
- // Also apply to audio pipeline to ensure consistency
- _audioPipeline?.SetVolume(_playerState.Volume);
- _audioPipeline?.SetMuted(_playerState.Muted);
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Failed to send initial client state");
- }
- }
-
- private void StartTimeSyncLoop()
- {
- // Stop existing loop if any
- StopTimeSyncLoop();
-
- _timeSyncCts = new CancellationTokenSource();
-
- // Fire-and-forget with proper exception handling
- TimeSyncLoopAsync(_timeSyncCts.Token).SafeFireAndForget(_logger);
-
- _logger.LogDebug("Time sync loop started (adaptive intervals)");
- }
-
- private void StopTimeSyncLoop()
- {
- _timeSyncCts?.Cancel();
- _timeSyncCts?.Dispose();
- _timeSyncCts = null;
- _logger.LogDebug("Time sync loop stopped");
- }
-
- ///
- /// Calculates the next time sync interval based on synchronization quality.
- /// Uses longer intervals when well-synced to improve drift measurement signal-to-noise ratio.
- ///
- private int GetAdaptiveTimeSyncIntervalMs()
- {
- var status = _clockSynchronizer.GetStatus();
-
- // If not enough measurements yet, sync rapidly (but after burst, so this is inter-burst interval)
- if (status.MeasurementCount < 3)
- return 500; // 500ms between initial bursts
-
- // Uncertainty in milliseconds
- var uncertaintyMs = status.OffsetUncertaintyMicroseconds / 1000.0;
-
- // Adaptive intervals based on sync quality
- // Longer intervals when synced = better drift signal detection over time
- if (uncertaintyMs < 1.0)
- return 10000; // Well synchronized: 10s (allows drift to accumulate measurably)
- else if (uncertaintyMs < 2.0)
- return 5000; // Good sync: 5s
- else if (uncertaintyMs < 5.0)
- return 2000; // Moderate sync: 2s
- else
- return 1000; // Poor sync: 1s
- }
-
- private async Task TimeSyncLoopAsync(CancellationToken cancellationToken)
- {
- try
- {
- while (!cancellationToken.IsCancellationRequested && _connection.State == ConnectionState.Connected)
- {
- // Send burst of time sync messages
- await SendTimeSyncBurstAsync(cancellationToken);
-
- // Calculate adaptive interval based on current sync quality
- var intervalMs = GetAdaptiveTimeSyncIntervalMs();
-
- _logger.LogTrace("Next time sync burst in {Interval}ms (uncertainty: {Uncertainty:F2}ms)",
- intervalMs,
- _clockSynchronizer.GetStatus().OffsetUncertaintyMicroseconds / 1000.0);
-
- // Wait for the interval before next burst
- await Task.Delay(intervalMs, cancellationToken);
- }
- }
- catch (OperationCanceledException)
- {
- // Normal cancellation
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Time sync loop ended unexpectedly");
- }
- }
-
- ///
- /// Sends a burst of time sync messages and waits for responses.
- /// Only the measurement with the smallest RTT is used (best quality).
- ///
- private async Task SendTimeSyncBurstAsync(CancellationToken cancellationToken)
- {
- if (_connection.State != ConnectionState.Connected)
- return;
-
- try
- {
- // Clear previous burst results
- lock (_burstLock)
- {
- _burstResults.Clear();
- _pendingBurstTimestamps.Clear();
- }
-
- // Send burst of messages
- for (int i = 0; i < BurstSize; i++)
- {
- if (cancellationToken.IsCancellationRequested || _connection.State != ConnectionState.Connected)
- break;
-
- var timeMessage = ClientTimeMessage.CreateNow();
-
- lock (_burstLock)
- {
- _pendingBurstTimestamps.Add(timeMessage.ClientTransmitted);
- }
-
- await _connection.SendMessageAsync(timeMessage, cancellationToken);
- _logger.LogTrace("Sent burst message {Index}/{Total}: T1={T1}", i + 1, BurstSize, timeMessage.ClientTransmitted);
-
- // Wait between burst messages (except after last one)
- if (i < BurstSize - 1)
- {
- await Task.Delay(BurstIntervalMs, cancellationToken);
- }
- }
-
- // Wait for responses to arrive (give extra time after last send)
- await Task.Delay(BurstIntervalMs * 2, cancellationToken);
-
- // Process the best result from the burst
- ProcessBurstResults();
- }
- catch (OperationCanceledException)
- {
- // Ignore cancellation
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Failed to send time sync burst");
- }
- }
-
- ///
- /// Processes collected burst results and feeds the best measurement to the Kalman filter.
- ///
- private void ProcessBurstResults()
- {
- (long t1, long t2, long t3, long t4, double rtt) bestResult;
- int totalResults;
-
- lock (_burstLock)
- {
- totalResults = _burstResults.Count;
- if (totalResults == 0)
- {
- _logger.LogDebug("No burst results to process");
- return;
- }
-
- // Find the measurement with smallest RTT (best quality)
- bestResult = _burstResults.OrderBy(r => r.rtt).First();
- _burstResults.Clear();
- _pendingBurstTimestamps.Clear();
- }
-
- _logger.LogDebug("Processing best of {Count} burst results: RTT={RTT:F0}μs",
- totalResults, bestResult.rtt);
-
- // Track if we were already converged before this measurement
- bool wasConverged = _clockSynchronizer.IsConverged;
-
- // Feed only the best measurement to the Kalman filter
- _clockSynchronizer.ProcessMeasurement(bestResult.t1, bestResult.t2, bestResult.t3, bestResult.t4);
-
- // Log the sync status periodically
- var status = _clockSynchronizer.GetStatus();
- if (status.MeasurementCount <= 10 || status.MeasurementCount % 10 == 0)
- {
- _logger.LogDebug(
- "Clock sync: offset={Offset:F2}ms (±{Uncertainty:F2}ms), drift={Drift:F2}μs/s, converged={Converged}, driftReliable={DriftReliable}",
- status.OffsetMilliseconds,
- status.OffsetUncertaintyMicroseconds / 1000.0,
- status.DriftMicrosecondsPerSecond,
- status.IsConverged,
- status.IsDriftReliable);
- }
-
- // Notify when first converged
- if (!wasConverged && _clockSynchronizer.IsConverged)
- {
- _logger.LogInformation("[ClockSync] Converged after {Count} measurements", status.MeasurementCount);
- ClockSyncConverged?.Invoke(this, status);
- }
- }
-
- private void HandleServerTime(string json)
- {
- var message = MessageSerializer.Deserialize(json);
- if (message is null) return;
-
- // Record receive time (T4)
- var t4 = ClientTimeMessage.GetCurrentTimestampMicroseconds();
-
- // NTP timestamps: T1=client sent, T2=server received, T3=server sent, T4=client received
- var t1 = message.ClientTransmitted;
- var t2 = message.ServerReceived;
- var t3 = message.ServerTransmitted;
-
- // Calculate RTT: (T4 - T1) - (T3 - T2) = network round-trip excluding server processing
- double rtt = (t4 - t1) - (t3 - t2);
-
- lock (_burstLock)
- {
- // Check if this is a response to a burst message we sent
- if (_pendingBurstTimestamps.Contains(t1))
- {
- // Collect this result for later processing
- _burstResults.Add((t1, t2, t3, t4, rtt));
- _pendingBurstTimestamps.Remove(t1);
- _logger.LogTrace("Collected burst response: RTT={RTT:F0}μs ({Count} collected)",
- rtt, _burstResults.Count);
- return;
- }
- }
-
- // If not part of a burst (shouldn't happen normally), process immediately
- _logger.LogDebug("Processing non-burst time response: RTT={RTT:F0}μs", rtt);
- _clockSynchronizer.ProcessMeasurement(t1, t2, t3, t4);
- }
-
- private void HandleGroupUpdate(string json)
- {
- var message = MessageSerializer.Deserialize(json);
- if (message is null) return;
-
- // Create group state if needed
- _currentGroup ??= new GroupState();
-
- var previousGroupId = _currentGroup.GroupId;
- var previousName = _currentGroup.Name;
-
- // group/update contains: group_id, group_name, playback_state
- // Volume, mute, metadata come via server/state (handled in HandleServerState)
- if (!string.IsNullOrEmpty(message.GroupId))
- _currentGroup.GroupId = message.GroupId;
- if (!string.IsNullOrEmpty(message.GroupName))
- _currentGroup.Name = message.GroupName;
- if (message.PlaybackState.HasValue)
- _currentGroup.PlaybackState = message.PlaybackState.Value;
-
- // Log group ID changes (helps diagnose grouping issues)
- if (previousGroupId != _currentGroup.GroupId && !string.IsNullOrEmpty(previousGroupId))
- {
- _logger.LogInformation("group/update [{Player}]: Group ID changed {OldId} -> {NewId}",
- _capabilities.ClientName, previousGroupId, _currentGroup.GroupId);
- }
-
- // Log group name changes
- if (previousName != _currentGroup.Name && _currentGroup.Name is not null)
- {
- _logger.LogInformation("group/update [{Player}]: Group name changed '{OldName}' -> '{NewName}'",
- _capabilities.ClientName, previousName ?? "(none)", _currentGroup.Name);
- }
-
- _logger.LogDebug("group/update [{Player}]: GroupId={GroupId}, Name={Name}, State={State}",
- _capabilities.ClientName,
- _currentGroup.GroupId,
- _currentGroup.Name ?? "(none)",
- _currentGroup.PlaybackState);
-
- GroupStateChanged?.Invoke(this, _currentGroup);
- }
-
- private void HandleServerState(string json)
- {
- var message = MessageSerializer.Deserialize(json);
- if (message is null) return;
-
- var payload = message.Payload;
- _currentGroup ??= new GroupState();
-
- // Update metadata from server/state (merge with existing to preserve data across partial updates)
- if (payload.Metadata is not null)
- {
- var meta = payload.Metadata;
- var existing = _currentGroup.Metadata ?? new TrackMetadata();
-
- // Only update fields that are present in the message
- // For Progress, we use Optional to distinguish between:
- // - Absent: keep existing value (partial update)
- // - Present but null: clear progress (track ended)
- // - Present with value: update progress
- _currentGroup.Metadata = new TrackMetadata
- {
- Timestamp = meta.Timestamp ?? existing.Timestamp,
- Title = meta.Title ?? existing.Title,
- Artist = meta.Artist ?? existing.Artist,
- AlbumArtist = meta.AlbumArtist ?? existing.AlbumArtist,
- Album = meta.Album ?? existing.Album,
- ArtworkUrl = meta.ArtworkUrl ?? existing.ArtworkUrl,
- Year = meta.Year ?? existing.Year,
- Track = meta.Track ?? existing.Track,
- Progress = meta.Progress.IsPresent ? meta.Progress.Value : existing.Progress,
- Repeat = meta.Repeat ?? existing.Repeat,
- Shuffle = meta.Shuffle ?? existing.Shuffle
- };
-
- // Update group-level shuffle/repeat from metadata
- if (meta.Shuffle.HasValue)
- _currentGroup.Shuffle = meta.Shuffle.Value;
- if (meta.Repeat is not null)
- _currentGroup.Repeat = meta.Repeat;
- }
-
- // Update controller state (volume, mute) for UI display only.
- // Do NOT apply to audio pipeline - server/state contains GROUP volume.
- // The server sends server/command with player-specific volume when it wants
- // to change THIS player's output.
- if (payload.Controller is not null)
- {
- if (payload.Controller.Volume.HasValue)
- _currentGroup.Volume = payload.Controller.Volume.Value;
- if (payload.Controller.Muted.HasValue)
- _currentGroup.Muted = payload.Controller.Muted.Value;
- }
-
- _logger.LogDebug("server/state [{Player}]: Volume={Volume}, Muted={Muted}, Track={Track} by {Artist}",
- _capabilities.ClientName,
- _currentGroup.Volume,
- _currentGroup.Muted,
- _currentGroup.Metadata?.Title ?? "unknown",
- _currentGroup.Metadata?.Artist ?? "unknown");
-
- GroupStateChanged?.Invoke(this, _currentGroup);
- }
-
- ///
- /// Handles server/command messages that instruct the player to apply volume or mute changes.
- /// These commands originate from controller clients and are relayed by the server to all players.
- ///
- ///
- /// Per the Sendspin spec, after applying a server/command, the player MUST send a client/state
- /// message back to acknowledge the change. This allows the server to:
- /// 1. Confirm the player received and applied the command
- /// 2. Recalculate the group average from actual player states
- /// 3. Broadcast updated group state to controllers
- ///
- private void HandleServerCommand(string json)
- {
- var message = MessageSerializer.Deserialize(json);
- if (message?.Payload?.Player is null)
- {
- _logger.LogDebug("server/command: No player command in message");
- return;
- }
-
- var player = message.Payload.Player;
- var changed = false;
-
- _logger.LogDebug("server/command: {Command}", player.Command);
-
- // Apply volume change - update player state and audio pipeline
- // Note: This updates _playerState (THIS player's volume), not _currentGroup (group average)
- if (player.Volume.HasValue)
- {
- _playerState.Volume = player.Volume.Value;
- _audioPipeline?.SetVolume(player.Volume.Value);
- changed = true;
- _logger.LogInformation("server/command [{Player}]: Applied volume {Volume}",
- _capabilities.ClientName, player.Volume.Value);
- }
-
- // Apply mute change - update player state and audio pipeline
- if (player.Mute.HasValue)
- {
- _playerState.Muted = player.Mute.Value;
- _audioPipeline?.SetMuted(player.Mute.Value);
- changed = true;
- _logger.LogInformation("server/command [{Player}]: Applied mute {Muted}",
- _capabilities.ClientName, player.Mute.Value);
- }
-
- if (changed)
- {
- // Notify listeners of player state change
- PlayerStateChanged?.Invoke(this, _playerState);
-
- // ACK: send client/state to confirm applied state back to server.
- SendPlayerStateAckAsync().SafeFireAndForget(_logger);
- }
- }
-
- ///
- /// Sends a client/state acknowledgement after applying a server/command.
- /// Reports current player volume and mute state back to the server.
- ///
- private async Task SendPlayerStateAckAsync()
- {
- await SendPlayerStateAsync(_playerState.Volume, _playerState.Muted, _clockSynchronizer.StaticDelayMs);
- }
-
- ///
- /// Handles client/sync_offset messages from GroupSync calibration tool.
- /// Applies the calculated offset to the static delay for speaker synchronization.
- ///
- private void HandleSyncOffset(string json)
- {
- var message = MessageSerializer.Deserialize(json);
- if (message?.Payload is null)
- {
- _logger.LogWarning("client/sync_offset: Invalid message format");
- return;
- }
-
- var payload = message.Payload;
- _logger.LogInformation(
- "client/sync_offset: Applying offset {Offset}ms from {Source}",
- payload.OffsetMs,
- payload.Source ?? "unknown");
-
- // Clamp offset to reasonable range (-5000 to +5000 ms)
- const double MinOffset = -5000.0;
- const double MaxOffset = 5000.0;
- var clampedOffset = Math.Clamp(payload.OffsetMs, MinOffset, MaxOffset);
-
- if (Math.Abs(clampedOffset - payload.OffsetMs) > 0.001)
- {
- _logger.LogWarning(
- "[ClockSync] sync_offset: Offset clamped from {Original}ms to {Clamped}ms",
- payload.OffsetMs,
- clampedOffset);
- }
-
- // Apply the offset to the clock synchronizer
- _clockSynchronizer.StaticDelayMs = clampedOffset;
-
- _logger.LogDebug("[ClockSync] Static delay set to {Delay:+0.0;-0.0}ms", clampedOffset);
-
- // Raise event for UI notification
- SyncOffsetApplied?.Invoke(this, new SyncOffsetEventArgs(payload.PlayerId, clampedOffset, payload.Source));
- }
-
- private async Task HandleStreamStartAsync(string json)
- {
- var message = MessageSerializer.Deserialize(json);
- if (message is null)
- {
- return;
- }
-
- // stream/start with no "player" key is artwork-only — skip pipeline start
- if (message.Format is null)
- {
- _logger.LogDebug("Stream start is artwork-only (no player key), skipping pipeline start");
- return;
- }
-
- _logger.LogInformation("Stream starting: {Format}", message.Format);
-
- // Clear any stale chunks from previous streams
- while (_earlyChunkQueue.TryDequeue(out _))
- {
- }
-
- // Smart sync burst: only trigger if clock isn't already synced
- // If we've been connected for a while, the continuous sync loop has already converged
- if (!_clockSynchronizer.HasMinimalSync)
- {
- _logger.LogDebug("Clock not synced, triggering re-sync burst (fire-and-forget)");
- _ = SendTimeSyncBurstAsync(CancellationToken.None);
- }
- else
- {
- _logger.LogDebug("Clock already synced ({MeasurementCount} measurements), skipping burst",
- _clockSynchronizer.GetStatus()?.MeasurementCount ?? 0);
- }
-
- // Start pipeline immediately - don't block on sync burst
- // The continuous sync loop + sync correction will handle any residual drift
- if (_audioPipeline != null)
- {
- try
- {
- await _audioPipeline.StartAsync(message.Format);
-
- // Drain any chunks that arrived during initialization
- var drainedCount = 0;
- while (_earlyChunkQueue.TryDequeue(out var chunk))
- {
- _audioPipeline.ProcessAudioChunk(chunk);
- drainedCount++;
- }
-
- if (drainedCount > 0)
- {
- _logger.LogDebug("Drained {Count} early chunks into pipeline", drainedCount);
- }
-
- // Infer Playing state from stream/start for servers that don't send group/update
- _currentGroup ??= new GroupState();
- _currentGroup.PlaybackState = PlaybackState.Playing;
- GroupStateChanged?.Invoke(this, _currentGroup);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to start audio pipeline");
- }
- }
- }
-
- private async Task HandleStreamEndAsync(string json)
- {
- var message = MessageSerializer.Deserialize(json);
- _logger.LogInformation("Stream ended: {Reason}", message?.Reason ?? "unknown");
-
- // Clear any queued chunks from this stream
- while (_earlyChunkQueue.TryDequeue(out _))
- {
- }
-
- if (_audioPipeline != null)
- {
- try
- {
- await _audioPipeline.StopAsync();
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to stop audio pipeline");
- }
- }
-
- // Update playback state to reflect stream ended
- if (_currentGroup != null)
- {
- _currentGroup.PlaybackState = PlaybackState.Idle;
- GroupStateChanged?.Invoke(this, _currentGroup);
- }
- }
-
- private void HandleStreamClear(string json)
- {
- var message = MessageSerializer.Deserialize(json);
- _logger.LogDebug("Stream clear (seek)");
-
- _audioPipeline?.Clear();
- }
-
- private void OnBinaryMessageReceived(object? sender, ReadOnlyMemory data)
- {
- if (!BinaryMessageParser.TryParse(data.Span, out var type, out var timestamp, out var payload))
- {
- _logger.LogWarning("Failed to parse binary message");
- return;
- }
-
- var category = BinaryMessageParser.GetCategory(type);
-
- switch (category)
- {
- case BinaryMessageCategory.PlayerAudio:
- var audioChunk = BinaryMessageParser.ParseAudioChunk(data.Span);
- if (audioChunk != null)
- {
- if (_audioPipeline?.IsReady == true)
- {
- // Pipeline ready - process immediately
- _audioPipeline.ProcessAudioChunk(audioChunk);
- }
- else if (_earlyChunkQueue.Count < MaxEarlyChunks)
- {
- // Pipeline not ready yet - queue for later processing
- // This prevents chunk loss during decoder/buffer initialization
- _earlyChunkQueue.Enqueue(audioChunk);
- _logger.LogTrace("Queued early chunk ({QueueSize} in queue)", _earlyChunkQueue.Count);
- }
- // else: queue full, drop chunk (should rarely happen)
- }
-
- _logger.LogTrace("Audio chunk: {Length} bytes @ {Timestamp}", payload.Length, timestamp);
- break;
-
- case BinaryMessageCategory.Artwork:
- var artwork = BinaryMessageParser.ParseArtworkChunk(data.Span);
- if (artwork is not null)
- {
- 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;
-
- case BinaryMessageCategory.Visualizer:
- // TODO: Handle visualizer data
- break;
- }
- }
-
- public async ValueTask DisposeAsync()
- {
- if (_disposed) return;
- _disposed = true;
-
- StopTimeSyncLoop();
-
- // NOTE: We do NOT dispose _audioPipeline here - it's a shared singleton
- // managed by the DI container. We only stop playback if active.
- if (_audioPipeline != null)
- {
- await _audioPipeline.StopAsync();
- }
-
- _connection.StateChanged -= OnConnectionStateChanged;
- _connection.TextMessageReceived -= OnTextMessageReceived;
- _connection.BinaryMessageReceived -= OnBinaryMessageReceived;
-
- await _connection.DisposeAsync();
- }
-}
diff --git a/src/Sendspin.SDK/Client/SendSpinHostService.cs b/src/Sendspin.SDK/Client/SendSpinHostService.cs
deleted file mode 100644
index 33b1b34..0000000
--- a/src/Sendspin.SDK/Client/SendSpinHostService.cs
+++ /dev/null
@@ -1,735 +0,0 @@
-using Microsoft.Extensions.Logging;
-using Sendspin.SDK.Audio;
-using Sendspin.SDK.Connection;
-using Sendspin.SDK.Discovery;
-using Sendspin.SDK.Extensions;
-using Sendspin.SDK.Models;
-using Sendspin.SDK.Protocol;
-using Sendspin.SDK.Protocol.Messages;
-using Sendspin.SDK.Synchronization;
-
-namespace Sendspin.SDK.Client;
-
-///
-/// Hosts a Sendspin client service that accepts incoming server connections.
-/// This is the server-initiated mode where:
-/// 1. We run a WebSocket server
-/// 2. We advertise via mDNS as _sendspin._tcp.local.
-/// 3. Sendspin servers discover and connect to us
-///
-public sealed class SendspinHostService : IAsyncDisposable
-{
- private readonly ILogger _logger;
- private readonly ILoggerFactory _loggerFactory;
- private readonly SendspinListener _listener;
- private readonly MdnsServiceAdvertiser _advertiser;
- private readonly ClientCapabilities _capabilities;
- private readonly IAudioPipeline? _audioPipeline;
- private readonly IClockSynchronizer? _clockSynchronizer;
-
- private readonly Dictionary _connections = new();
- private readonly object _connectionsLock = new();
-
- ///
- /// Whether the host is running (listening and advertising).
- ///
- public bool IsRunning => _listener.IsListening && _advertiser.IsAdvertising;
-
- ///
- /// Whether the service is currently being advertised via mDNS.
- ///
- public bool IsAdvertising => _advertiser.IsAdvertising;
-
- ///
- /// The client ID being advertised.
- ///
- public string ClientId => _advertiser.ClientId;
-
- ///
- /// Currently connected servers.
- ///
- public IReadOnlyList ConnectedServers
- {
- get
- {
- lock (_connectionsLock)
- {
- return _connections.Values
- .Where(c => c.Client.ConnectionState == ConnectionState.Connected)
- .Select(c => new ConnectedServerInfo
- {
- ServerId = c.ServerId,
- ServerName = c.Client.ServerName ?? c.ServerId,
- ConnectedAt = c.ConnectedAt,
- ClockSyncStatus = c.Client.ClockSyncStatus
- })
- .ToList();
- }
- }
- }
-
- ///
- /// Raised when a new server connects and completes handshake.
- ///
- public event EventHandler? ServerConnected;
-
- ///
- /// Raised when a server disconnects.
- ///
- public event EventHandler? ServerDisconnected;
-
- ///
- /// Raised when playback state changes on any connection.
- ///
- public event EventHandler? GroupStateChanged;
-
- ///
- /// Raised when this player's volume or mute state is changed by a server command.
- ///
- public event EventHandler? PlayerStateChanged;
-
- ///
- /// Raised when artwork is received.
- ///
- 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.
- ///
- public event EventHandler? LastPlayedServerIdChanged;
-
- ///
- /// Gets the server ID of the server that most recently had playback_state "playing".
- /// Used for tie-breaking when multiple servers with the same connection_reason try to connect.
- ///
- public string? LastPlayedServerId { get; private set; }
-
- ///
- /// Updates the last-played server ID.
- /// Call this when a server transitions to the "playing" state, regardless of connection mode.
- ///
- /// The server ID that is now playing.
- public void SetLastPlayedServerId(string serverId)
- {
- if (string.IsNullOrEmpty(serverId) || serverId == LastPlayedServerId)
- return;
-
- LastPlayedServerId = serverId;
- _logger.LogInformation("Last played server updated: {ServerId}", serverId);
- LastPlayedServerIdChanged?.Invoke(this, serverId);
- }
-
- public SendspinHostService(
- ILoggerFactory loggerFactory,
- ClientCapabilities? capabilities = null,
- ListenerOptions? listenerOptions = null,
- AdvertiserOptions? advertiserOptions = null,
- IAudioPipeline? audioPipeline = null,
- IClockSynchronizer? clockSynchronizer = null,
- string? lastPlayedServerId = null)
- {
- _loggerFactory = loggerFactory;
- _logger = loggerFactory.CreateLogger();
- _capabilities = capabilities ?? new ClientCapabilities();
- _audioPipeline = audioPipeline;
- _clockSynchronizer = clockSynchronizer;
- LastPlayedServerId = lastPlayedServerId;
-
- // Ensure options are consistent
- var listenOpts = listenerOptions ?? new ListenerOptions();
- var advertiseOpts = advertiserOptions ?? new AdvertiserOptions
- {
- ClientId = _capabilities.ClientId,
- PlayerName = _capabilities.ClientName,
- Port = listenOpts.Port,
- Path = listenOpts.Path
- };
-
- _listener = new SendspinListener(
- loggerFactory.CreateLogger(),
- listenOpts);
-
- _advertiser = new MdnsServiceAdvertiser(
- loggerFactory.CreateLogger(),
- advertiseOpts);
-
- _listener.ServerConnected += OnServerConnected;
- }
-
- ///
- /// Starts the host service (listener + mDNS advertisement).
- ///
- public async Task StartAsync(CancellationToken cancellationToken = default)
- {
- _logger.LogInformation("Starting Sendspin host service");
-
- // Start the WebSocket listener first
- await _listener.StartAsync(cancellationToken);
-
- // Then advertise via mDNS
- await _advertiser.StartAsync(cancellationToken);
-
- _logger.LogInformation("Sendspin host service started - waiting for server connections");
- }
-
- ///
- /// Stops the host service.
- ///
- public async Task StopAsync()
- {
- _logger.LogInformation("Stopping Sendspin host service");
-
- // Stop advertising first
- await _advertiser.StopAsync();
-
- // Disconnect all clients
- List connectionsToClose;
- lock (_connectionsLock)
- {
- connectionsToClose = _connections.Values.ToList();
- _connections.Clear();
- }
-
- foreach (var conn in connectionsToClose)
- {
- try
- {
- await conn.Client.DisconnectAsync("host_stopping");
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Error disconnecting from {ServerId}", conn.ServerId);
- }
- }
-
- // Stop the listener
- await _listener.StopAsync();
-
- _logger.LogInformation("Sendspin host service stopped");
- }
-
- ///
- /// Stops mDNS advertising without stopping the listener.
- /// Call this when manually connecting to a server to prevent
- /// other servers from trying to connect to this client.
- ///
- public async Task StopAdvertisingAsync()
- {
- if (!_advertiser.IsAdvertising)
- return;
-
- _logger.LogInformation("Stopping mDNS advertisement (manual connection active)");
- await _advertiser.StopAsync();
- }
-
- ///
- /// Resumes mDNS advertising after it was stopped.
- /// Call this when disconnecting from a manually connected server
- /// to allow servers to discover this client again.
- ///
- public async Task StartAdvertisingAsync(CancellationToken cancellationToken = default)
- {
- if (_advertiser.IsAdvertising)
- return;
-
- if (!_listener.IsListening)
- {
- _logger.LogWarning("Cannot start advertising - listener is not running");
- return;
- }
-
- _logger.LogInformation("Resuming mDNS advertisement");
- await _advertiser.StartAsync(cancellationToken);
- }
-
- ///
- /// Disconnects all currently connected servers.
- /// Use when switching to a client-initiated connection to ensure
- /// only one connection is using the audio pipeline at a time.
- ///
- public async Task DisconnectAllAsync(string reason = "switching_connection_mode")
- {
- List connectionsToClose;
- lock (_connectionsLock)
- {
- connectionsToClose = _connections.Values.ToList();
- _connections.Clear();
- }
-
- foreach (var conn in connectionsToClose)
- {
- try
- {
- _logger.LogInformation("Disconnecting server {ServerId}: {Reason}", conn.ServerId, reason);
- await conn.Client.DisconnectAsync(reason);
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Error disconnecting from {ServerId}", conn.ServerId);
- }
- }
- }
-
- private async void OnServerConnected(object? sender, WebSocketClientConnection webSocket)
- {
- // All code must be inside try-catch since async void exceptions crash the app
- string? connectionId = null;
- try
- {
- connectionId = Guid.NewGuid().ToString("N")[..8];
- _logger.LogInformation("New server connection: {ConnectionId}", connectionId);
- // Create connection wrapper for WebSocket
- var connection = new IncomingConnection(
- _loggerFactory.CreateLogger(),
- webSocket);
-
- // Create client service to handle the protocol
- // Use the shared clock synchronizer if provided, otherwise create a per-connection one
- var clockSync = _clockSynchronizer
- ?? new KalmanClockSynchronizer(_loggerFactory.CreateLogger());
- var client = new SendspinClientService(
- _loggerFactory.CreateLogger(),
- connection,
- clockSync,
- _capabilities,
- _audioPipeline);
-
- // Subscribe to forwarded events (GroupState, PlayerState, Artwork)
- client.GroupStateChanged += (s, g) =>
- {
- // Track which server last had playback_state "playing"
- if (g.PlaybackState == PlaybackState.Playing && client.ServerId is not null)
- {
- SetLastPlayedServerId(client.ServerId);
- }
-
- GroupStateChanged?.Invoke(this, g);
- };
- 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();
-
- // Send client hello - we always send this first per the protocol
- await SendClientHelloAsync(client, connection);
-
- // Wait for handshake to complete
- if (!await WaitForHandshakeAsync(client, connection, connectionId))
- {
- return;
- }
-
- // Handshake complete - now arbitrate whether to accept this server
- var serverId = client.ServerId ?? connectionId;
-
- // Perform multi-server arbitration: determine whether the new server
- // should replace the existing one or be rejected
- if (!await ArbitrateConnectionAsync(client, connection, serverId))
- {
- // New server lost arbitration - it has already been disconnected
- return;
- }
-
- // Subscribe to connection state AFTER handshake so we use the correct serverId
- client.ConnectionStateChanged += (s, e) => OnClientConnectionStateChanged(serverId, e);
- var activeConnection = new ActiveServerConnection
- {
- ServerId = serverId,
- Client = client,
- Connection = connection,
- ConnectedAt = DateTime.UtcNow
- };
-
- lock (_connectionsLock)
- {
- _connections[serverId] = activeConnection;
- }
-
- _logger.LogInformation("Server connected: {ServerId} ({ServerName})",
- serverId, client.ServerName);
-
- ServerConnected?.Invoke(this, new ConnectedServerInfo
- {
- ServerId = serverId,
- ServerName = client.ServerName ?? serverId,
- ConnectedAt = activeConnection.ConnectedAt,
- ClockSyncStatus = client.ClockSyncStatus
- });
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error handling server connection {ConnectionId}", connectionId ?? "unknown");
- }
- }
-
- private async Task SendClientHelloAsync(SendspinClientService client, IncomingConnection connection)
- {
- // Use audio formats from capabilities (order matters - server picks first supported)
- var hello = ClientHelloMessage.Create(
- clientId: _capabilities.ClientId,
- name: _capabilities.ClientName,
- supportedRoles: _capabilities.Roles,
- playerSupport: new PlayerSupport
- {
- SupportedFormats = _capabilities.AudioFormats
- .Select(f => new AudioFormatSpec
- {
- Codec = f.Codec,
- Channels = f.Channels,
- SampleRate = f.SampleRate,
- BitDepth = f.BitDepth ?? 16,
- })
- .ToList(),
- BufferCapacity = _capabilities.BufferCapacity,
- SupportedCommands = new List { "volume", "mute" }
- },
- artworkSupport: new ArtworkSupport
- {
- Channels = new List
- {
- new ArtworkChannelSpec
- {
- Source = "album",
- Format = _capabilities.ArtworkFormats.FirstOrDefault() ?? "jpeg",
- MediaWidth = _capabilities.ArtworkMaxSize,
- MediaHeight = _capabilities.ArtworkMaxSize
- }
- }
- },
- deviceInfo: new DeviceInfo
- {
- ProductName = _capabilities.ProductName,
- Manufacturer = _capabilities.Manufacturer,
- SoftwareVersion = _capabilities.SoftwareVersion
- }
- );
-
- var helloJson = MessageSerializer.Serialize(hello);
- _logger.LogInformation("Sending client/hello:\n{Json}", helloJson);
- await connection.SendMessageAsync(hello);
- }
-
- ///
- /// Waits for the handshake to complete with timeout.
- ///
- /// The client service to monitor.
- /// The connection to disconnect on timeout.
- /// Connection ID for logging.
- /// Handshake timeout in seconds (default: 10).
- /// True if handshake completed successfully, false otherwise.
- private async Task WaitForHandshakeAsync(
- SendspinClientService client,
- IncomingConnection connection,
- string connectionId,
- int timeoutSeconds = 10)
- {
- var handshakeComplete = new TaskCompletionSource();
-
- void OnStateChanged(object? s, ConnectionStateChangedEventArgs e)
- {
- if (e.NewState == ConnectionState.Connected)
- {
- handshakeComplete.TrySetResult(true);
- }
- else if (e.NewState == ConnectionState.Disconnected)
- {
- handshakeComplete.TrySetResult(false);
- }
- }
-
- client.ConnectionStateChanged += OnStateChanged;
-
- using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
- cts.Token.Register(() => handshakeComplete.TrySetCanceled());
-
- try
- {
- var success = await handshakeComplete.Task;
- if (!success)
- {
- _logger.LogWarning("Handshake failed for connection {ConnectionId}", connectionId);
- }
- return success;
- }
- catch (OperationCanceledException)
- {
- _logger.LogWarning("Handshake timeout for connection {ConnectionId}", connectionId);
- await connection.DisconnectAsync("handshake_timeout");
- return false;
- }
- finally
- {
- client.ConnectionStateChanged -= OnStateChanged;
- }
- }
-
- ///
- /// Arbitrates whether a newly handshaked server should become the active connection.
- /// Only one server can be active at a time. Priority rules:
- /// 1. "playback" connection_reason beats "discovery"
- /// 2. If tied, the last-played server wins
- /// 3. If still tied (or LastPlayedServerId is null), the existing server wins
- ///
- /// The new client that just completed handshake.
- /// The new connection to disconnect if rejected.
- /// The server ID of the new connection.
- /// True if the new server is accepted, false if rejected.
- private async Task ArbitrateConnectionAsync(
- SendspinClientService newClient,
- IncomingConnection newConnection,
- string newServerId)
- {
- ActiveServerConnection? existingConnection = null;
-
- lock (_connectionsLock)
- {
- // Find the current active connection (there should be at most one)
- existingConnection = _connections.Values.FirstOrDefault();
- }
-
- // No existing server - accept the new one unconditionally
- if (existingConnection is null)
- {
- _logger.LogInformation(
- "Arbitration: Accepting {NewServerId} (no existing connection)",
- newServerId);
- return true;
- }
-
- var existingServerId = existingConnection.ServerId;
-
- // If the same server is reconnecting, accept it (replace the stale entry)
- if (string.Equals(newServerId, existingServerId, StringComparison.Ordinal))
- {
- _logger.LogInformation(
- "Arbitration: Accepting {NewServerId} (same server reconnecting)",
- newServerId);
-
- // Disconnect the old connection cleanly
- await DisconnectExistingAsync(existingConnection, "reconnecting");
- return true;
- }
-
- // Normalize connection reasons: null is treated as "discovery"
- var newReason = newClient.ConnectionReason ?? "discovery";
- var existingReason = existingConnection.Client.ConnectionReason ?? "discovery";
-
- bool newWins;
- string decision;
-
- if (string.Equals(newReason, "playback", StringComparison.OrdinalIgnoreCase)
- && !string.Equals(existingReason, "playback", StringComparison.OrdinalIgnoreCase))
- {
- // New server has playback reason, existing does not - new wins
- newWins = true;
- decision = "new server has playback reason";
- }
- else if (string.Equals(existingReason, "playback", StringComparison.OrdinalIgnoreCase)
- && !string.Equals(newReason, "playback", StringComparison.OrdinalIgnoreCase))
- {
- // Existing server has playback reason, new does not - existing wins
- newWins = false;
- decision = "existing server has playback reason";
- }
- else
- {
- // Tie: both have same reason - check LastPlayedServerId
- if (LastPlayedServerId is not null
- && string.Equals(newServerId, LastPlayedServerId, StringComparison.Ordinal))
- {
- newWins = true;
- decision = "new server matches LastPlayedServerId (tie-break)";
- }
- else
- {
- // Existing wins by default (including when LastPlayedServerId is null)
- newWins = false;
- decision = LastPlayedServerId is not null
- ? "existing server wins tie-break (new server is not LastPlayedServerId)"
- : "existing server wins tie-break (no LastPlayedServerId set)";
- }
- }
-
- _logger.LogInformation(
- "Arbitration: {Winner} wins. New={NewServerId} (reason={NewReason}), " +
- "Existing={ExistingServerId} (reason={ExistingReason}). Decision: {Decision}",
- newWins ? newServerId : existingServerId,
- newServerId, newReason,
- existingServerId, existingReason,
- decision);
-
- if (newWins)
- {
- // Disconnect the existing server
- await DisconnectExistingAsync(existingConnection, "another_server");
- return true;
- }
- else
- {
- // Reject the new server
- _logger.LogInformation(
- "Arbitration: Rejecting {NewServerId}, sending goodbye",
- newServerId);
-
- try
- {
- await newConnection.DisconnectAsync("another_server");
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Error disconnecting rejected server {ServerId}", newServerId);
- }
-
- return false;
- }
- }
-
- ///
- /// Disconnects an existing active server connection during arbitration.
- /// Removes the connection from the tracking dictionary and sends a goodbye message.
- ///
- /// The existing connection to disconnect.
- /// The goodbye reason to send.
- private async Task DisconnectExistingAsync(ActiveServerConnection existing, string reason)
- {
- lock (_connectionsLock)
- {
- _connections.Remove(existing.ServerId);
- }
-
- _logger.LogInformation(
- "Arbitration: Disconnecting existing server {ServerId} with reason {Reason}",
- existing.ServerId, reason);
-
- try
- {
- await existing.Client.DisconnectAsync(reason);
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Error disconnecting existing server {ServerId} during arbitration",
- existing.ServerId);
- }
-
- ServerDisconnected?.Invoke(this, existing.ServerId);
- }
-
- private void OnClientConnectionStateChanged(string connectionId, ConnectionStateChangedEventArgs e)
- {
- if (e.NewState == ConnectionState.Disconnected)
- {
- lock (_connectionsLock)
- {
- var entry = _connections.FirstOrDefault(c => c.Value.ServerId == connectionId);
- // FirstOrDefault returns default(KeyValuePair) when not found, which has Key=null.
- // This check works because dictionary keys are never null (serverId falls back to GUID).
- if (entry.Key is not null)
- {
- _connections.Remove(entry.Key);
- _logger.LogInformation("Server disconnected: {ServerId}", entry.Key);
- ServerDisconnected?.Invoke(this, entry.Key);
- }
- }
- }
- }
-
- ///
- /// Sends a command to a specific server or all connected servers.
- ///
- public async Task SendCommandAsync(string command, Dictionary? parameters = null, string? serverId = null)
- {
- List clients;
- lock (_connectionsLock)
- {
- if (serverId != null)
- {
- if (_connections.TryGetValue(serverId, out var conn))
- {
- clients = new List { conn.Client };
- }
- else
- {
- throw new InvalidOperationException($"Server {serverId} not connected");
- }
- }
- else
- {
- clients = _connections.Values.Select(c => c.Client).ToList();
- }
- }
-
- foreach (var client in clients)
- {
- await client.SendCommandAsync(command, parameters);
- }
- }
-
- ///
- /// Sends the current player state (volume, muted) to a specific server or all connected servers.
- ///
- /// 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)
- {
- if (serverId != null)
- {
- if (_connections.TryGetValue(serverId, out var conn))
- {
- clients = new List { conn.Client };
- }
- else
- {
- throw new InvalidOperationException($"Server {serverId} not connected");
- }
- }
- else
- {
- clients = _connections.Values.Select(c => c.Client).ToList();
- }
- }
-
- foreach (var client in clients)
- {
- await client.SendPlayerStateAsync(volume, muted, staticDelayMs);
- }
- }
-
- public async ValueTask DisposeAsync()
- {
- await StopAsync();
- await _listener.DisposeAsync();
- await _advertiser.DisposeAsync();
- }
-
- private class ActiveServerConnection
- {
- required public string ServerId { get; init; }
- required public SendspinClientService Client { get; init; }
- required public IncomingConnection Connection { get; init; }
- public DateTime ConnectedAt { get; init; }
- }
-}
-
-///
-/// Information about a connected Sendspin server.
-///
-public record ConnectedServerInfo
-{
- required public string ServerId { get; init; }
- required public string ServerName { get; init; }
- public DateTime ConnectedAt { get; init; }
- public ClockSyncStatus? ClockSyncStatus { get; init; }
-}
diff --git a/src/Sendspin.SDK/Client/SyncOffsetEventArgs.cs b/src/Sendspin.SDK/Client/SyncOffsetEventArgs.cs
deleted file mode 100644
index dc34eca..0000000
--- a/src/Sendspin.SDK/Client/SyncOffsetEventArgs.cs
+++ /dev/null
@@ -1,30 +0,0 @@
-namespace Sendspin.SDK.Client;
-
-///
-/// Event args for sync offset applied from GroupSync calibration.
-///
-public sealed class SyncOffsetEventArgs : EventArgs
-{
- ///
- /// The player ID that the offset was applied to.
- ///
- public string PlayerId { get; }
-
- ///
- /// The offset in milliseconds that was applied.
- /// Positive = delay playback, Negative = advance playback.
- ///
- public double OffsetMs { get; }
-
- ///
- /// The source of the calibration (e.g., "groupsync", "manual").
- ///
- public string? Source { get; }
-
- public SyncOffsetEventArgs(string playerId, double offsetMs, string? source = null)
- {
- PlayerId = playerId;
- OffsetMs = offsetMs;
- Source = source;
- }
-}
diff --git a/src/Sendspin.SDK/Connection/ConnectionOptions.cs b/src/Sendspin.SDK/Connection/ConnectionOptions.cs
deleted file mode 100644
index e2f0bac..0000000
--- a/src/Sendspin.SDK/Connection/ConnectionOptions.cs
+++ /dev/null
@@ -1,49 +0,0 @@
-namespace Sendspin.SDK.Connection;
-
-///
-/// Configuration options for the Sendspin connection.
-///
-public sealed class ConnectionOptions
-{
- ///
- /// Maximum number of reconnection attempts before giving up.
- /// Set to -1 for infinite retries.
- ///
- public int MaxReconnectAttempts { get; set; } = -1;
-
- ///
- /// Initial delay between reconnection attempts in milliseconds.
- ///
- public int ReconnectDelayMs { get; set; } = 1000;
-
- ///
- /// Maximum delay between reconnection attempts in milliseconds.
- ///
- public int MaxReconnectDelayMs { get; set; } = 30000;
-
- ///
- /// Multiplier for exponential backoff.
- ///
- public double ReconnectBackoffMultiplier { get; set; } = 2.0;
-
- ///
- /// Connection timeout in milliseconds.
- ///
- public int ConnectTimeoutMs { get; set; } = 10000;
-
- ///
- /// Interval for sending keep-alive pings in milliseconds.
- /// Set to 0 to disable.
- ///
- public int KeepAliveIntervalMs { get; set; } = 30000;
-
- ///
- /// Buffer size for receiving WebSocket messages.
- ///
- public int ReceiveBufferSize { get; set; } = 64 * 1024; // 64KB
-
- ///
- /// Whether to automatically reconnect on connection loss.
- ///
- public bool AutoReconnect { get; set; } = true;
-}
diff --git a/src/Sendspin.SDK/Connection/ConnectionState.cs b/src/Sendspin.SDK/Connection/ConnectionState.cs
deleted file mode 100644
index 7352c85..0000000
--- a/src/Sendspin.SDK/Connection/ConnectionState.cs
+++ /dev/null
@@ -1,48 +0,0 @@
-namespace Sendspin.SDK.Connection;
-
-///
-/// Represents the current state of the WebSocket connection.
-///
-public enum ConnectionState
-{
- ///
- /// Not connected to any server.
- ///
- Disconnected,
-
- ///
- /// Attempting to establish connection.
- ///
- Connecting,
-
- ///
- /// Connected but waiting for handshake completion.
- ///
- Handshaking,
-
- ///
- /// Fully connected and authenticated.
- ///
- Connected,
-
- ///
- /// Connection lost, attempting to reconnect.
- ///
- Reconnecting,
-
- ///
- /// Gracefully disconnecting.
- ///
- Disconnecting
-}
-
-///
-/// Event args for connection state changes.
-///
-public sealed class ConnectionStateChangedEventArgs : EventArgs
-{
- public ConnectionState OldState { get; init; }
- public ConnectionState NewState { get; init; }
- public string? Reason { get; init; }
- public Exception? Exception { get; init; }
-}
diff --git a/src/Sendspin.SDK/Connection/ISendSpinConnection.cs b/src/Sendspin.SDK/Connection/ISendSpinConnection.cs
deleted file mode 100644
index b585e49..0000000
--- a/src/Sendspin.SDK/Connection/ISendSpinConnection.cs
+++ /dev/null
@@ -1,58 +0,0 @@
-using Sendspin.SDK.Protocol.Messages;
-
-namespace Sendspin.SDK.Connection;
-
-///
-/// Interface for the Sendspin WebSocket connection.
-///
-public interface ISendspinConnection : IAsyncDisposable
-{
- ///
- /// Current connection state.
- ///
- ConnectionState State { get; }
-
- ///
- /// The URI of the currently connected server.
- ///
- Uri? ServerUri { get; }
-
- ///
- /// Connects to a Sendspin server.
- ///
- /// WebSocket URI (e.g., ws://host:port/sendspin)
- /// Cancellation token
- Task ConnectAsync(Uri serverUri, CancellationToken cancellationToken = default);
-
- ///
- /// Disconnects from the server.
- ///
- /// Reason for disconnection
- /// Cancellation token
- Task DisconnectAsync(string reason = "user_request", CancellationToken cancellationToken = default);
-
- ///
- /// Sends a JSON protocol message.
- ///
- Task SendMessageAsync(T message, CancellationToken cancellationToken = default) where T : IMessage;
-
- ///
- /// Sends raw binary data.
- ///
- Task SendBinaryAsync(ReadOnlyMemory data, CancellationToken cancellationToken = default);
-
- ///
- /// Event raised when connection state changes.
- ///
- event EventHandler? StateChanged;
-
- ///
- /// Event raised when a text (JSON) message is received.
- ///
- event EventHandler? TextMessageReceived;
-
- ///
- /// Event raised when a binary message is received.
- ///
- event EventHandler>? BinaryMessageReceived;
-}
diff --git a/src/Sendspin.SDK/Connection/IncomingConnection.cs b/src/Sendspin.SDK/Connection/IncomingConnection.cs
deleted file mode 100644
index 23d3df2..0000000
--- a/src/Sendspin.SDK/Connection/IncomingConnection.cs
+++ /dev/null
@@ -1,214 +0,0 @@
-using System.Text;
-using Microsoft.Extensions.Logging;
-using Sendspin.SDK.Protocol;
-using Sendspin.SDK.Protocol.Messages;
-
-namespace Sendspin.SDK.Connection;
-
-///
-/// Wraps an incoming WebSocket connection from a Sendspin server.
-/// Used for server-initiated connections where the server connects to us.
-///
-public sealed class IncomingConnection : ISendspinConnection
-{
- private readonly ILogger _logger;
- private readonly WebSocketClientConnection _socket;
- private readonly SemaphoreSlim _sendLock = new(1, 1);
-
- private ConnectionState _state = ConnectionState.Disconnected;
- private bool _disposed;
- private bool _isOpen;
-
- public ConnectionState State => _state;
- public Uri? ServerUri { get; private set; }
-
- public event EventHandler? StateChanged;
- public event EventHandler? TextMessageReceived;
- public event EventHandler>? BinaryMessageReceived;
-
- public IncomingConnection(
- ILogger logger,
- WebSocketClientConnection socket)
- {
- _logger = logger;
- _socket = socket;
-
- // Get server address from connection info
- var clientIp = socket.ClientIpAddress;
- var clientPort = socket.ClientPort;
- ServerUri = new Uri($"ws://{clientIp}:{clientPort}");
-
- // Wire up events
- _socket.OnMessage = OnTextMessage;
- _socket.OnBinary = OnBinaryMessage;
- _socket.OnClose = OnClose;
- _socket.OnError = OnError;
- }
-
- ///
- /// Starts processing messages on this connection.
- /// For incoming connections, this just marks the connection as ready.
- ///
- public Task StartAsync(CancellationToken cancellationToken = default)
- {
- ObjectDisposedException.ThrowIf(_disposed, this);
-
- if (_state != ConnectionState.Disconnected)
- {
- throw new InvalidOperationException($"Cannot start while in state {_state}");
- }
-
- _isOpen = true;
- SetState(ConnectionState.Handshaking);
-
- return Task.CompletedTask;
- }
-
- ///
- /// Not used for incoming connections - throws InvalidOperationException.
- ///
- public Task ConnectAsync(Uri serverUri, CancellationToken cancellationToken = default)
- {
- throw new InvalidOperationException(
- "IncomingConnection does not support outgoing connections. " +
- "Use SendspinConnection for client-initiated connections.");
- }
-
- public async Task DisconnectAsync(string reason = "user_request", CancellationToken cancellationToken = default)
- {
- if (_state == ConnectionState.Disconnected || !_isOpen)
- return;
-
- SetState(ConnectionState.Disconnecting, reason);
-
- try
- {
- if (_isOpen)
- {
- try
- {
- var goodbye = ClientGoodbyeMessage.Create(reason);
- await SendMessageAsync(goodbye, cancellationToken);
-
- await _socket.CloseAsync(cancellationToken);
- }
- catch (Exception ex)
- {
- _logger.LogDebug(ex, "Error during graceful disconnect");
- }
- }
- }
- finally
- {
- _isOpen = false;
- SetState(ConnectionState.Disconnected, reason);
- }
- }
-
- public async Task SendMessageAsync(T message, CancellationToken cancellationToken = default) where T : IMessage
- {
- ObjectDisposedException.ThrowIf(_disposed, this);
-
- if (!_isOpen)
- {
- throw new InvalidOperationException("WebSocket is not connected");
- }
-
- var json = MessageSerializer.Serialize(message);
-
- await _sendLock.WaitAsync(cancellationToken).ConfigureAwait(false);
- try
- {
- _logger.LogDebug("Sending: {Message}", json);
- await _socket.SendAsync(json).ConfigureAwait(false);
- }
- finally
- {
- _sendLock.Release();
- }
- }
-
- public async Task SendBinaryAsync(ReadOnlyMemory data, CancellationToken cancellationToken = default)
- {
- ObjectDisposedException.ThrowIf(_disposed, this);
-
- if (!_isOpen)
- {
- throw new InvalidOperationException("WebSocket is not connected");
- }
-
- await _sendLock.WaitAsync(cancellationToken).ConfigureAwait(false);
- try
- {
- await _socket.SendAsync(data.ToArray()).ConfigureAwait(false);
- }
- finally
- {
- _sendLock.Release();
- }
- }
-
- ///
- /// Marks the connection as fully connected (called after handshake).
- ///
- public void MarkConnected()
- {
- if (_state == ConnectionState.Handshaking)
- {
- SetState(ConnectionState.Connected);
- }
- }
-
- private void OnTextMessage(string message)
- {
- _logger.LogDebug("Received text: {Message}", message.Length > 500 ? message[..500] + "..." : message);
- TextMessageReceived?.Invoke(this, message);
- }
-
- private void OnBinaryMessage(byte[] data)
- {
- _logger.LogTrace("Received binary: {Length} bytes", data.Length);
- BinaryMessageReceived?.Invoke(this, data);
- }
-
- private void OnClose()
- {
- _logger.LogInformation("Server closed connection");
- _isOpen = false;
- SetState(ConnectionState.Disconnected, "Connection closed by server");
- }
-
- private void OnError(Exception ex)
- {
- _logger.LogError(ex, "WebSocket error");
- _isOpen = false;
- SetState(ConnectionState.Disconnected, ex.Message, ex);
- }
-
- private void SetState(ConnectionState newState, string? reason = null, Exception? exception = null)
- {
- var oldState = _state;
- if (oldState == newState) return;
-
- _state = newState;
- _logger.LogDebug("Connection state: {OldState} -> {NewState} ({Reason})",
- oldState, newState, reason ?? "N/A");
-
- StateChanged?.Invoke(this, new ConnectionStateChangedEventArgs
- {
- OldState = oldState,
- NewState = newState,
- Reason = reason,
- Exception = exception
- });
- }
-
- public async ValueTask DisposeAsync()
- {
- if (_disposed) return;
- _disposed = true;
-
- await DisconnectAsync("disposing");
- _sendLock.Dispose();
- }
-}
diff --git a/src/Sendspin.SDK/Connection/SendSpinConnection.cs b/src/Sendspin.SDK/Connection/SendSpinConnection.cs
deleted file mode 100644
index c2b4b88..0000000
--- a/src/Sendspin.SDK/Connection/SendSpinConnection.cs
+++ /dev/null
@@ -1,431 +0,0 @@
-using System.Buffers;
-using System.Net.WebSockets;
-using System.Text;
-using Microsoft.Extensions.Logging;
-using Sendspin.SDK.Protocol;
-using Sendspin.SDK.Protocol.Messages;
-
-namespace Sendspin.SDK.Connection;
-
-///
-/// WebSocket connection to a Sendspin server.
-/// Handles connection lifecycle, message sending/receiving, and automatic reconnection.
-///
-public sealed class SendspinConnection : ISendspinConnection
-{
- private readonly ILogger _logger;
- private readonly ConnectionOptions _options;
- private readonly SemaphoreSlim _sendLock = new(1, 1);
-
- private ClientWebSocket? _webSocket;
- private CancellationTokenSource? _receiveCts;
- private Task? _receiveTask;
- private Uri? _serverUri;
- private ConnectionState _state = ConnectionState.Disconnected;
- private int _reconnectAttempt;
- private int _connectionLostGuard;
- private bool _disposed;
-
- public ConnectionState State => _state;
- public Uri? ServerUri => _serverUri;
-
- public event EventHandler? StateChanged;
- public event EventHandler? TextMessageReceived;
- public event EventHandler>? BinaryMessageReceived;
-
- public SendspinConnection(ILogger logger, ConnectionOptions? options = null)
- {
- _logger = logger;
- _options = options ?? new ConnectionOptions();
- }
-
- public async Task ConnectAsync(Uri serverUri, CancellationToken cancellationToken = default)
- {
- ObjectDisposedException.ThrowIf(_disposed, this);
-
- if (_state is ConnectionState.Connected or ConnectionState.Connecting)
- {
- throw new InvalidOperationException($"Cannot connect while in state {_state}");
- }
-
- _serverUri = serverUri;
- _reconnectAttempt = 0;
-
- try
- {
- await ConnectInternalAsync(cancellationToken);
- }
- catch (OperationCanceledException)
- {
- throw;
- }
- catch (Exception)
- {
- if (_options.AutoReconnect && !cancellationToken.IsCancellationRequested)
- {
- // Initial connection failed - enter reconnection loop
- SetState(ConnectionState.Reconnecting, "Initial connection failed");
- await TryReconnectAsync(cancellationToken);
- }
- else
- {
- SetState(ConnectionState.Disconnected, "Connection failed");
- throw;
- }
- }
- }
-
- private async Task ConnectInternalAsync(CancellationToken cancellationToken)
- {
- if (_serverUri is null)
- throw new InvalidOperationException("Server URI not set");
-
- SetState(ConnectionState.Connecting);
-
- try
- {
- // Clean up previous connection
- await CleanupWebSocketAsync();
-
- _webSocket = new ClientWebSocket();
- _webSocket.Options.KeepAliveInterval = TimeSpan.FromMilliseconds(_options.KeepAliveIntervalMs);
-
- using var timeoutCts = new CancellationTokenSource(_options.ConnectTimeoutMs);
- using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
-
- _logger.LogInformation("Connecting to {Uri}...", _serverUri);
- await _webSocket.ConnectAsync(_serverUri, linkedCts.Token);
-
- _logger.LogInformation("Connected to {Uri}", _serverUri);
- _reconnectAttempt = 0;
-
- // Start receive loop
- _receiveCts = new CancellationTokenSource();
- _receiveTask = ReceiveLoopAsync(_receiveCts.Token);
-
- SetState(ConnectionState.Handshaking);
- }
- catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
- {
- SetState(ConnectionState.Disconnected, "Connection cancelled");
- throw;
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to connect to {Uri}", _serverUri);
- // Let caller decide how to handle failure.
- // TryReconnectAsync's loop handles retries without transitioning through Disconnected.
- // ConnectAsync handles initial connection failure.
- throw;
- }
- }
-
- public async Task DisconnectAsync(string reason = "user_request", CancellationToken cancellationToken = default)
- {
- if (_state == ConnectionState.Disconnected)
- return;
-
- SetState(ConnectionState.Disconnecting, reason);
-
- try
- {
- // Send goodbye message if connected
- if (_webSocket?.State == WebSocketState.Open)
- {
- try
- {
- var goodbye = ClientGoodbyeMessage.Create(reason);
- await SendMessageAsync(goodbye, cancellationToken);
-
- await _webSocket.CloseAsync(
- WebSocketCloseStatus.NormalClosure,
- reason,
- cancellationToken);
- }
- catch (Exception ex)
- {
- _logger.LogDebug(ex, "Error during graceful disconnect");
- }
- }
- }
- finally
- {
- await CleanupWebSocketAsync();
- SetState(ConnectionState.Disconnected, reason);
- }
- }
-
- public async Task SendMessageAsync(T message, CancellationToken cancellationToken = default) where T : IMessage
- {
- ObjectDisposedException.ThrowIf(_disposed, this);
-
- if (_webSocket?.State != WebSocketState.Open)
- {
- // Trigger connection lost handling if receive loop hasn't detected it yet.
- // This handles the race where WebSocket detected closure but ReceiveAsync is still blocking.
- if (_state is ConnectionState.Connected or ConnectionState.Handshaking)
- {
- _ = Task.Run(() => HandleConnectionLostAsync());
- }
-
- throw new InvalidOperationException("WebSocket is not connected");
- }
-
- var json = MessageSerializer.Serialize(message);
- var bytes = Encoding.UTF8.GetBytes(json);
-
- await _sendLock.WaitAsync(cancellationToken);
- try
- {
- _logger.LogDebug("Sending: {Message}", json);
- await _webSocket.SendAsync(
- new ArraySegment(bytes),
- WebSocketMessageType.Text,
- endOfMessage: true,
- cancellationToken);
- }
- finally
- {
- _sendLock.Release();
- }
- }
-
- public async Task SendBinaryAsync(ReadOnlyMemory data, CancellationToken cancellationToken = default)
- {
- ObjectDisposedException.ThrowIf(_disposed, this);
-
- if (_webSocket?.State != WebSocketState.Open)
- {
- if (_state is ConnectionState.Connected or ConnectionState.Handshaking)
- {
- _ = Task.Run(() => HandleConnectionLostAsync());
- }
-
- throw new InvalidOperationException("WebSocket is not connected");
- }
-
- await _sendLock.WaitAsync(cancellationToken);
- try
- {
- await _webSocket.SendAsync(
- data,
- WebSocketMessageType.Binary,
- endOfMessage: true,
- cancellationToken);
- }
- finally
- {
- _sendLock.Release();
- }
- }
-
- private async Task ReceiveLoopAsync(CancellationToken cancellationToken)
- {
- var buffer = ArrayPool.Shared.Rent(_options.ReceiveBufferSize);
- var messageBuffer = new MemoryStream();
-
- try
- {
- while (!cancellationToken.IsCancellationRequested && _webSocket?.State == WebSocketState.Open)
- {
- WebSocketReceiveResult result;
- messageBuffer.SetLength(0);
-
- do
- {
- result = await _webSocket.ReceiveAsync(
- new ArraySegment(buffer),
- cancellationToken);
-
- if (result.MessageType == WebSocketMessageType.Close)
- {
- _logger.LogInformation("Server closed connection: {Status} - {Description}",
- result.CloseStatus, result.CloseStatusDescription);
- return;
- }
-
- messageBuffer.Write(buffer, 0, result.Count);
- }
- while (!result.EndOfMessage);
-
- var messageData = messageBuffer.ToArray();
-
- if (result.MessageType == WebSocketMessageType.Text)
- {
- var text = Encoding.UTF8.GetString(messageData);
- _logger.LogDebug("Received text: {Message}", text.Length > 500 ? text[..500] + "..." : text);
- TextMessageReceived?.Invoke(this, text);
- }
- else if (result.MessageType == WebSocketMessageType.Binary)
- {
- _logger.LogTrace("Received binary: {Length} bytes", messageData.Length);
- BinaryMessageReceived?.Invoke(this, messageData);
- }
- }
- }
- catch (OperationCanceledException)
- {
- // Normal cancellation
- }
- catch (WebSocketException ex) when (ex.WebSocketErrorCode == WebSocketError.ConnectionClosedPrematurely)
- {
- _logger.LogWarning("Connection closed unexpectedly");
- await HandleConnectionLostAsync();
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error in receive loop");
- await HandleConnectionLostAsync();
- }
- finally
- {
- ArrayPool.Shared.Return(buffer);
- messageBuffer.Dispose();
- }
- }
-
- private async Task HandleConnectionLostAsync()
- {
- if (_state == ConnectionState.Disconnecting || _disposed)
- return;
-
- // Atomic guard - only the first caller proceeds, prevents duplicate reconnection attempts
- // when both send failure and receive loop detect connection loss simultaneously
- if (Interlocked.CompareExchange(ref _connectionLostGuard, 1, 0) == 1)
- {
- _logger.LogDebug("Connection loss already being handled, skipping duplicate call");
- return;
- }
-
- try
- {
- SetState(ConnectionState.Reconnecting, "Connection lost");
-
- if (_options.AutoReconnect)
- {
- await TryReconnectAsync(CancellationToken.None);
- }
- else
- {
- SetState(ConnectionState.Disconnected, "Connection lost");
- }
- }
- finally
- {
- Interlocked.Exchange(ref _connectionLostGuard, 0);
- }
- }
-
- private async Task TryReconnectAsync(CancellationToken cancellationToken)
- {
- while (!cancellationToken.IsCancellationRequested && !_disposed)
- {
- if (_options.MaxReconnectAttempts >= 0 && _reconnectAttempt >= _options.MaxReconnectAttempts)
- {
- _logger.LogWarning("Max reconnection attempts ({Max}) reached", _options.MaxReconnectAttempts);
- SetState(ConnectionState.Disconnected, "Max reconnection attempts reached");
- return;
- }
-
- _reconnectAttempt++;
- var delay = CalculateReconnectDelay();
-
- _logger.LogInformation("Reconnecting in {Delay}ms (attempt {Attempt})...", delay, _reconnectAttempt);
- SetState(ConnectionState.Reconnecting, $"Attempt {_reconnectAttempt}");
-
- try
- {
- await Task.Delay(delay, cancellationToken);
- await ConnectInternalAsync(cancellationToken);
-
- if (_state == ConnectionState.Handshaking || _state == ConnectionState.Connected)
- {
- return; // Successfully reconnected
- }
- }
- catch (OperationCanceledException)
- {
- break;
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Reconnection attempt {Attempt} failed", _reconnectAttempt);
- }
- }
- }
-
- private int CalculateReconnectDelay()
- {
- var delay = (int)(_options.ReconnectDelayMs * Math.Pow(_options.ReconnectBackoffMultiplier, _reconnectAttempt - 1));
- return Math.Min(delay, _options.MaxReconnectDelayMs);
- }
-
- private async Task CleanupWebSocketAsync()
- {
- _receiveCts?.Cancel();
-
- if (_receiveTask is not null)
- {
- try
- {
- await _receiveTask.WaitAsync(TimeSpan.FromSeconds(2));
- }
- catch (TimeoutException)
- {
- _logger.LogDebug("Receive task cleanup timeout (expected during shutdown)");
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Unexpected error during receive task cleanup");
- }
- }
-
- _receiveCts?.Dispose();
- _receiveCts = null;
- _receiveTask = null;
-
- if (_webSocket is not null)
- {
- _webSocket.Dispose();
- _webSocket = null;
- }
- }
-
- private void SetState(ConnectionState newState, string? reason = null, Exception? exception = null)
- {
- var oldState = _state;
- if (oldState == newState) return;
-
- _state = newState;
- _logger.LogDebug("Connection state: {OldState} -> {NewState} ({Reason})",
- oldState, newState, reason ?? "N/A");
-
- StateChanged?.Invoke(this, new ConnectionStateChangedEventArgs
- {
- OldState = oldState,
- NewState = newState,
- Reason = reason,
- Exception = exception
- });
- }
-
- ///
- /// Marks the connection as fully connected (called after handshake).
- ///
- public void MarkConnected()
- {
- if (_state == ConnectionState.Handshaking)
- {
- SetState(ConnectionState.Connected);
- }
- }
-
- public async ValueTask DisposeAsync()
- {
- if (_disposed) return;
- _disposed = true;
-
- await DisconnectAsync("disposing");
- _sendLock.Dispose();
- }
-}
diff --git a/src/Sendspin.SDK/Connection/SendSpinListener.cs b/src/Sendspin.SDK/Connection/SendSpinListener.cs
deleted file mode 100644
index 0e1e783..0000000
--- a/src/Sendspin.SDK/Connection/SendSpinListener.cs
+++ /dev/null
@@ -1,131 +0,0 @@
-using Microsoft.Extensions.Logging;
-
-namespace Sendspin.SDK.Connection;
-
-///
-/// WebSocket server listener for server-initiated Sendspin connections.
-/// Uses a built-in .NET WebSocket server (no admin privileges required).
-/// Listens on the configured port and accepts incoming connections from Sendspin servers.
-///
-public sealed class SendspinListener : IAsyncDisposable
-{
- private readonly ILogger _logger;
- private readonly ListenerOptions _options;
- private SimpleWebSocketServer? _server;
- private bool _disposed;
- private bool _isListening;
-
- ///
- /// Raised when a new server connects.
- ///
- public event EventHandler? ServerConnected;
-
- ///
- /// Whether the listener is currently running.
- ///
- public bool IsListening => _isListening;
-
- ///
- /// The port the listener is bound to.
- ///
- public int Port => _options.Port;
-
- public SendspinListener(ILogger logger, ListenerOptions? options = null)
- {
- _logger = logger;
- _options = options ?? new ListenerOptions();
- }
-
- ///
- /// Starts listening for incoming WebSocket connections.
- ///
- public Task StartAsync(CancellationToken cancellationToken = default)
- {
- ObjectDisposedException.ThrowIf(_disposed, this);
-
- if (_isListening)
- {
- _logger.LogWarning("Listener is already running");
- return Task.CompletedTask;
- }
-
- _server = new SimpleWebSocketServer(_logger);
- _server.ClientConnected += OnClientConnected;
- _server.Start(_options.Port);
-
- _isListening = true;
- _logger.LogInformation("Sendspin listener started on ws://0.0.0.0:{Port} (path: {Path})",
- _options.Port, _options.Path);
-
- return Task.CompletedTask;
- }
-
- private void OnClientConnected(object? sender, WebSocketClientConnection connection)
- {
- _logger.LogInformation("WebSocket connection opened from {ClientIp}",
- connection.ClientIpAddress);
-
- // Check if this is the correct path
- var path = connection.Path;
- if (!string.Equals(path, _options.Path, StringComparison.OrdinalIgnoreCase) &&
- !string.Equals(path, _options.Path + "/", StringComparison.OrdinalIgnoreCase))
- {
- _logger.LogWarning("Connection to unexpected path: {Path}, expected: {Expected}",
- path, _options.Path);
- }
-
- // Raise the event
- ServerConnected?.Invoke(this, connection);
- }
-
- ///
- /// Stops listening for connections.
- ///
- public async Task StopAsync()
- {
- if (_server == null || !_isListening)
- return;
-
- _logger.LogInformation("Stopping Sendspin listener");
-
- try
- {
- await _server.StopAsync();
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Error stopping WebSocket server");
- }
- finally
- {
- _server = null;
- _isListening = false;
- }
- }
-
- public async ValueTask DisposeAsync()
- {
- if (_disposed) return;
- _disposed = true;
-
- await StopAsync();
- }
-}
-
-///
-/// Configuration options for the Sendspin listener.
-///
-public sealed class ListenerOptions
-{
- ///
- /// Port to listen on.
- /// Default: 8928 (Sendspin standard port for clients)
- ///
- public int Port { get; set; } = 8928;
-
- ///
- /// WebSocket endpoint path.
- /// Default: "/sendspin"
- ///
- public string Path { get; set; } = "/sendspin";
-}
diff --git a/src/Sendspin.SDK/Connection/SimpleWebSocketServer.cs b/src/Sendspin.SDK/Connection/SimpleWebSocketServer.cs
deleted file mode 100644
index 47de25b..0000000
--- a/src/Sendspin.SDK/Connection/SimpleWebSocketServer.cs
+++ /dev/null
@@ -1,242 +0,0 @@
-using System.Net;
-using System.Net.Sockets;
-using System.Net.WebSockets;
-using System.Security.Cryptography;
-using System.Text;
-using System.Text.RegularExpressions;
-using Microsoft.Extensions.Logging;
-
-namespace Sendspin.SDK.Connection;
-
-///
-/// Minimal WebSocket server using TcpListener + WebSocket.CreateFromStream().
-/// Replaces Fleck for NativeAOT compatibility — no HTTP.sys, no admin privileges.
-///
-public sealed partial class SimpleWebSocketServer : IAsyncDisposable
-{
- ///
- /// The WebSocket GUID used in the Sec-WebSocket-Accept computation per RFC 6455 Section 4.2.2.
- /// Note: Many online references incorrectly cite this as ending in "5AB0DC85B11C".
- /// The correct GUID from the RFC is "258EAFA5-E914-47DA-95CA-C5AB0DC85B11".
- ///
- private const string WebSocketGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
-
- private readonly ILogger? _logger;
- private TcpListener? _listener;
- private CancellationTokenSource? _cts;
- private Task? _acceptLoop;
- private bool _disposed;
-
- ///
- /// Port the server is listening on.
- ///
- public int Port { get; private set; }
-
- ///
- /// Raised when a new WebSocket client connects. The handler receives a
- /// with the receive loop already started.
- ///
- public event EventHandler? ClientConnected;
-
- public SimpleWebSocketServer(ILogger? logger = null)
- {
- _logger = logger;
- }
-
- ///
- /// Starts listening for incoming WebSocket connections.
- ///
- /// Port to bind to.
- public void Start(int port)
- {
- ObjectDisposedException.ThrowIf(_disposed, this);
-
- if (_listener is not null)
- throw new InvalidOperationException("Server is already running");
-
- _cts = new CancellationTokenSource();
- _listener = new TcpListener(IPAddress.Any, port);
- _listener.Start();
-
- // Read the actual bound port (important when port 0 is used for OS-assigned port)
- Port = ((IPEndPoint)_listener.LocalEndpoint).Port;
-
- _acceptLoop = Task.Run(() => AcceptLoopAsync(_cts.Token));
-
- _logger?.LogInformation("WebSocket server listening on port {Port}", Port);
- }
-
- ///
- /// Stops the server and closes all pending accept operations.
- ///
- public async Task StopAsync()
- {
- if (_listener is null) return;
-
- _logger?.LogInformation("Stopping WebSocket server");
-
- if (_cts is not null)
- {
- await _cts.CancelAsync();
- }
-
- _listener.Stop();
-
- if (_acceptLoop is not null)
- {
- try { await _acceptLoop.ConfigureAwait(false); }
- catch { /* Swallow — loop handles its own errors */ }
- }
-
- _listener = null;
- _cts?.Dispose();
- _cts = null;
- }
-
- private async Task AcceptLoopAsync(CancellationToken cancellationToken)
- {
- while (!cancellationToken.IsCancellationRequested)
- {
- try
- {
- var tcpClient = await _listener!.AcceptTcpClientAsync(cancellationToken)
- .ConfigureAwait(false);
-
- // Handle each connection concurrently
- _ = HandleConnectionAsync(tcpClient, cancellationToken);
- }
- catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
- {
- break;
- }
- catch (SocketException) when (cancellationToken.IsCancellationRequested)
- {
- break;
- }
- catch (ObjectDisposedException)
- {
- break;
- }
- catch (Exception ex)
- {
- _logger?.LogError(ex, "Error accepting TCP connection");
- }
- }
- }
-
- private async Task HandleConnectionAsync(TcpClient tcpClient, CancellationToken cancellationToken)
- {
- var remoteEndPoint = tcpClient.Client.RemoteEndPoint as IPEndPoint;
- try
- {
- var stream = tcpClient.GetStream();
-
- // Read the HTTP upgrade request
- var requestBytes = new byte[4096];
- var bytesRead = await stream.ReadAsync(requestBytes.AsMemory(), cancellationToken)
- .ConfigureAwait(false);
-
- if (bytesRead == 0)
- {
- tcpClient.Dispose();
- return;
- }
-
- var request = Encoding.UTF8.GetString(requestBytes, 0, bytesRead);
-
- // Parse the request
- var pathMatch = GetRequestLineRegex().Match(request);
- if (!pathMatch.Success)
- {
- _logger?.LogWarning("Invalid HTTP request from {Endpoint}", remoteEndPoint);
- await SendHttpResponse(stream, 400, "Bad Request", cancellationToken);
- tcpClient.Dispose();
- return;
- }
-
- var path = pathMatch.Groups[1].Value;
-
- // Extract WebSocket key
- var keyMatch = WebSocketKeyHeaderRegex().Match(request);
- if (!keyMatch.Success)
- {
- _logger?.LogWarning("Missing Sec-WebSocket-Key from {Endpoint}", remoteEndPoint);
- await SendHttpResponse(stream, 400, "Missing Sec-WebSocket-Key", cancellationToken);
- tcpClient.Dispose();
- return;
- }
-
- var webSocketKey = keyMatch.Groups[1].Value;
-
- // Compute Sec-WebSocket-Accept per RFC 6455
- var acceptKey = ComputeAcceptKey(webSocketKey);
-
- // Send HTTP 101 Switching Protocols
- var response = $"HTTP/1.1 101 Switching Protocols\r\n" +
- $"Upgrade: websocket\r\n" +
- $"Connection: Upgrade\r\n" +
- $"Sec-WebSocket-Accept: {acceptKey}\r\n" +
- $"\r\n";
-
- var responseBytes = Encoding.UTF8.GetBytes(response);
- await stream.WriteAsync(responseBytes.AsMemory(), cancellationToken)
- .ConfigureAwait(false);
-
- // Create WebSocket from the stream
- var webSocket = WebSocket.CreateFromStream(
- stream,
- new WebSocketCreationOptions { IsServer = true });
-
- var clientIp = remoteEndPoint?.Address ?? IPAddress.Loopback;
- var clientPort = remoteEndPoint?.Port ?? 0;
-
- var connection = new WebSocketClientConnection(
- webSocket,
- clientIp,
- clientPort,
- path,
- _logger);
-
- connection.StartReceiving();
-
- _logger?.LogDebug("WebSocket connection established from {Endpoint} on path {Path}",
- remoteEndPoint, path);
-
- ClientConnected?.Invoke(this, connection);
- }
- catch (Exception ex)
- {
- _logger?.LogError(ex, "Error handling WebSocket upgrade from {Endpoint}", remoteEndPoint);
- tcpClient.Dispose();
- }
- }
-
- private static string ComputeAcceptKey(string webSocketKey)
- {
- var combined = webSocketKey + WebSocketGuid;
- var hash = SHA1.HashData(Encoding.UTF8.GetBytes(combined));
- return Convert.ToBase64String(hash);
- }
-
- private static async Task SendHttpResponse(
- NetworkStream stream, int statusCode, string reason,
- CancellationToken cancellationToken)
- {
- var response = $"HTTP/1.1 {statusCode} {reason}\r\nContent-Length: 0\r\n\r\n";
- var bytes = Encoding.UTF8.GetBytes(response);
- await stream.WriteAsync(bytes.AsMemory(), cancellationToken).ConfigureAwait(false);
- }
-
- [GeneratedRegex(@"^GET\s+(\S+)\s+HTTP/1\.1", RegexOptions.Compiled)]
- private static partial Regex GetRequestLineRegex();
-
- [GeneratedRegex(@"Sec-WebSocket-Key:\s*(\S+)", RegexOptions.Compiled | RegexOptions.IgnoreCase)]
- private static partial Regex WebSocketKeyHeaderRegex();
-
- public async ValueTask DisposeAsync()
- {
- if (_disposed) return;
- _disposed = true;
- await StopAsync();
- }
-}
diff --git a/src/Sendspin.SDK/Connection/WebSocketClientConnection.cs b/src/Sendspin.SDK/Connection/WebSocketClientConnection.cs
deleted file mode 100644
index 4926236..0000000
--- a/src/Sendspin.SDK/Connection/WebSocketClientConnection.cs
+++ /dev/null
@@ -1,224 +0,0 @@
-using System.Buffers;
-using System.Net;
-using System.Net.WebSockets;
-using System.Text;
-using Microsoft.Extensions.Logging;
-
-namespace Sendspin.SDK.Connection;
-
-///
-/// Wraps a System.Net.WebSockets.WebSocket accepted by SimpleWebSocketServer.
-/// Provides event-based message dispatch (OnMessage, OnBinary, OnClose, OnError)
-/// and send methods, replacing Fleck's IWebSocketConnection.
-///
-public sealed class WebSocketClientConnection : IAsyncDisposable
-{
- private readonly WebSocket _webSocket;
- private readonly ILogger? _logger;
- private readonly CancellationTokenSource _cts = new();
- private Task? _receiveLoop;
- private bool _disposed;
-
- /// Client IP address.
- public IPAddress ClientIpAddress { get; }
-
- /// Client port.
- public int ClientPort { get; }
-
- /// The HTTP request path used during the WebSocket upgrade.
- public string Path { get; }
-
- /// Raised when a text message is received.
- public Action? OnMessage { get; set; }
-
- /// Raised when a binary message is received.
- public Action? OnBinary { get; set; }
-
- /// Raised when the connection closes.
- public Action? OnClose { get; set; }
-
- /// Raised when an error occurs.
- public Action? OnError { get; set; }
-
- public WebSocketClientConnection(
- WebSocket webSocket,
- IPAddress clientIpAddress,
- int clientPort,
- string path,
- ILogger? logger = null)
- {
- _webSocket = webSocket;
- ClientIpAddress = clientIpAddress;
- ClientPort = clientPort;
- Path = path;
- _logger = logger;
- }
-
- ///
- /// Starts the background receive loop that dispatches messages to callbacks.
- ///
- public void StartReceiving()
- {
- if (_receiveLoop is not null)
- throw new InvalidOperationException("Receive loop already started");
-
- _receiveLoop = Task.Run(() => ReceiveLoopAsync(_cts.Token));
- }
-
- ///
- /// Sends a text message.
- ///
- public async Task SendAsync(string message, CancellationToken cancellationToken = default)
- {
- ObjectDisposedException.ThrowIf(_disposed, this);
-
- if (_webSocket.State != WebSocketState.Open)
- throw new InvalidOperationException("WebSocket is not open");
-
- var bytes = Encoding.UTF8.GetBytes(message);
- await _webSocket.SendAsync(
- bytes.AsMemory(),
- WebSocketMessageType.Text,
- endOfMessage: true,
- cancellationToken).ConfigureAwait(false);
- }
-
- ///
- /// Sends a binary message.
- ///
- public async Task SendAsync(byte[] data, CancellationToken cancellationToken = default)
- {
- ObjectDisposedException.ThrowIf(_disposed, this);
-
- if (_webSocket.State != WebSocketState.Open)
- throw new InvalidOperationException("WebSocket is not open");
-
- await _webSocket.SendAsync(
- data.AsMemory(),
- WebSocketMessageType.Binary,
- endOfMessage: true,
- cancellationToken).ConfigureAwait(false);
- }
-
- ///
- /// Initiates a graceful WebSocket close.
- ///
- public async Task CloseAsync(CancellationToken cancellationToken = default)
- {
- if (_webSocket.State == WebSocketState.Open ||
- _webSocket.State == WebSocketState.CloseReceived)
- {
- try
- {
- await _webSocket.CloseAsync(
- WebSocketCloseStatus.NormalClosure,
- "closing",
- cancellationToken).ConfigureAwait(false);
- }
- catch (Exception ex) when (ex is WebSocketException or OperationCanceledException)
- {
- _logger?.LogDebug(ex, "Error during graceful WebSocket close");
- }
- }
- }
-
- private async Task ReceiveLoopAsync(CancellationToken cancellationToken)
- {
- var buffer = ArrayPool.Shared.Rent(8192);
- try
- {
- while (!cancellationToken.IsCancellationRequested &&
- _webSocket.State == WebSocketState.Open)
- {
- using var ms = new MemoryStream();
- WebSocketReceiveResult result;
-
- do
- {
- result = await _webSocket.ReceiveAsync(
- new ArraySegment(buffer),
- cancellationToken).ConfigureAwait(false);
-
- if (result.MessageType == WebSocketMessageType.Close)
- {
- // Complete the close handshake so the client isn't left waiting
- if (_webSocket.State == WebSocketState.CloseReceived)
- {
- try
- {
- await _webSocket.CloseOutputAsync(
- WebSocketCloseStatus.NormalClosure,
- string.Empty,
- cancellationToken).ConfigureAwait(false);
- }
- catch (Exception ex) when (ex is WebSocketException or OperationCanceledException)
- {
- _logger?.LogDebug(ex, "Error completing close handshake");
- }
- }
-
- OnClose?.Invoke();
- return;
- }
-
- ms.Write(buffer, 0, result.Count);
- }
- while (!result.EndOfMessage);
-
- var data = ms.ToArray();
-
- if (result.MessageType == WebSocketMessageType.Text)
- {
- var text = Encoding.UTF8.GetString(data);
- OnMessage?.Invoke(text);
- }
- else if (result.MessageType == WebSocketMessageType.Binary)
- {
- OnBinary?.Invoke(data);
- }
- }
- }
- catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
- {
- // Normal shutdown
- }
- catch (WebSocketException ex) when (
- _webSocket.State == WebSocketState.Aborted ||
- _webSocket.State == WebSocketState.Closed)
- {
- _logger?.LogDebug(ex, "WebSocket closed during receive");
- OnClose?.Invoke();
- }
- catch (Exception ex)
- {
- _logger?.LogError(ex, "WebSocket receive error");
- OnError?.Invoke(ex);
- }
- finally
- {
- ArrayPool.Shared.Return(buffer);
- if (_webSocket.State != WebSocketState.Closed &&
- _webSocket.State != WebSocketState.Aborted)
- {
- OnClose?.Invoke();
- }
- }
- }
-
- public async ValueTask DisposeAsync()
- {
- if (_disposed) return;
- _disposed = true;
-
- await _cts.CancelAsync();
-
- if (_receiveLoop is not null)
- {
- try { await _receiveLoop.ConfigureAwait(false); }
- catch { /* Swallow — loop handles its own errors */ }
- }
-
- _webSocket.Dispose();
- _cts.Dispose();
- }
-}
diff --git a/src/Sendspin.SDK/Diagnostics/DiagnosticAudioRingBuffer.cs b/src/Sendspin.SDK/Diagnostics/DiagnosticAudioRingBuffer.cs
deleted file mode 100644
index d26e6e1..0000000
--- a/src/Sendspin.SDK/Diagnostics/DiagnosticAudioRingBuffer.cs
+++ /dev/null
@@ -1,194 +0,0 @@
-//
-// Licensed under the MIT License. See LICENSE file in the project root.
-//
-
-namespace Sendspin.SDK.Diagnostics;
-
-///
-/// Lock-free single-producer single-consumer (SPSC) circular buffer for audio samples.
-/// Designed for minimal overhead on the audio thread.
-///
-///
-///
-/// This buffer is specifically designed for diagnostic audio capture where:
-///
-/// - Audio thread writes samples (producer) - must never block
-/// - Save thread reads samples (consumer) - can take its time
-///
-///
-///
-/// The buffer uses a power-of-2 size for efficient modulo operations and
-/// read/write for thread safety without locks.
-/// When the buffer is full, old samples are overwritten (circular behavior).
-///
-///
-public sealed class DiagnosticAudioRingBuffer
-{
- private readonly float[] _buffer;
- private readonly int _capacity;
- private readonly int _mask;
-
- // Write index - only modified by producer (audio thread)
- private long _writeIndex;
-
- // Sample rate and channel info for WAV output
- private readonly int _sampleRate;
- private readonly int _channels;
-
- ///
- /// Gets the sample rate of the audio in this buffer.
- ///
- public int SampleRate => _sampleRate;
-
- ///
- /// Gets the number of channels in the audio.
- ///
- public int Channels => _channels;
-
- ///
- /// Gets the buffer capacity in samples.
- ///
- public int Capacity => _capacity;
-
- ///
- /// Gets the total number of samples written since creation.
- /// This is a cumulative count that can exceed capacity (wraps around).
- ///
- public long TotalSamplesWritten => Volatile.Read(ref _writeIndex);
-
- ///
- /// Gets the duration of audio currently in the buffer in seconds.
- ///
- public double BufferedSeconds
- {
- get
- {
- var samplesWritten = Volatile.Read(ref _writeIndex);
- var samplesAvailable = Math.Min(samplesWritten, _capacity);
- return (double)samplesAvailable / _sampleRate / _channels;
- }
- }
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The audio sample rate (e.g., 48000).
- /// The number of audio channels (e.g., 2 for stereo).
- /// The buffer duration in seconds. Will be rounded up to power of 2.
- public DiagnosticAudioRingBuffer(int sampleRate, int channels, int durationSeconds = 45)
- {
- ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(sampleRate, 0);
- ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(channels, 0);
- ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(durationSeconds, 0);
-
- _sampleRate = sampleRate;
- _channels = channels;
-
- // Calculate required capacity and round up to next power of 2
- var requiredSamples = sampleRate * channels * durationSeconds;
- _capacity = RoundUpToPowerOfTwo(requiredSamples);
- _mask = _capacity - 1;
- _buffer = new float[_capacity];
- }
-
- ///
- /// Writes samples to the buffer. Called from the audio thread.
- ///
- ///
- /// This method is designed to be as fast as possible:
- ///
- /// - No locks
- /// - No allocations
- /// - No branches in the hot path (except the loop)
- ///
- /// When the buffer is full, old samples are overwritten.
- ///
- /// The samples to write.
- public void Write(ReadOnlySpan samples)
- {
- var writeIdx = Volatile.Read(ref _writeIndex);
-
- // Copy samples to buffer using mask for wrap-around
- // This is the hot path - keep it simple
- foreach (var sample in samples)
- {
- _buffer[writeIdx & _mask] = sample;
- writeIdx++;
- }
-
- Volatile.Write(ref _writeIndex, writeIdx);
- }
-
- ///
- /// Captures a snapshot of the current buffer contents.
- /// Called from the save thread (not audio thread).
- ///
- ///
- /// A tuple containing:
- ///
- /// - samples: Array of captured audio samples
- /// - startIndex: The cumulative sample index of the first sample in the array
- ///
- ///
- ///
- /// This method allocates a new array and copies the buffer contents.
- /// It should only be called from a background thread, not the audio thread.
- /// The returned startIndex can be used to correlate with .
- ///
- public (float[] Samples, long StartIndex) CaptureSnapshot()
- {
- // Read the current write position
- var writeIdx = Volatile.Read(ref _writeIndex);
-
- // Calculate how many samples we have (up to capacity)
- var samplesAvailable = (int)Math.Min(writeIdx, _capacity);
- var startIdx = writeIdx - samplesAvailable;
-
- // Allocate result array
- var result = new float[samplesAvailable];
-
- // Copy samples in correct order
- for (var i = 0; i < samplesAvailable; i++)
- {
- result[i] = _buffer[(startIdx + i) & _mask];
- }
-
- return (result, startIdx);
- }
-
- ///
- /// Resets the buffer to empty state.
- ///
- ///
- /// This should only be called when the audio thread is not writing.
- ///
- public void Clear()
- {
- Volatile.Write(ref _writeIndex, 0);
- Array.Clear(_buffer);
- }
-
- ///
- /// Rounds a value up to the next power of 2.
- ///
- private static int RoundUpToPowerOfTwo(int value)
- {
- if (value <= 0)
- {
- return 1;
- }
-
- // Subtract 1 to handle exact powers of 2
- value--;
-
- // Spread the highest bit to all lower positions
- value |= value >> 1;
- value |= value >> 2;
- value |= value >> 4;
- value |= value >> 8;
- value |= value >> 16;
-
- // Add 1 to get the next power of 2
- return value + 1;
- }
-}
diff --git a/src/Sendspin.SDK/Diagnostics/SyncMetricRingBuffer.cs b/src/Sendspin.SDK/Diagnostics/SyncMetricRingBuffer.cs
deleted file mode 100644
index 75cfbb1..0000000
--- a/src/Sendspin.SDK/Diagnostics/SyncMetricRingBuffer.cs
+++ /dev/null
@@ -1,138 +0,0 @@
-//
-// Licensed under the MIT License. See LICENSE file in the project root.
-//
-
-namespace Sendspin.SDK.Diagnostics;
-
-///
-/// Lock-free circular buffer for sync metric snapshots.
-/// Stores metrics at regular intervals for correlation with audio samples.
-///
-///
-///
-/// At 100ms intervals, a 45-second buffer needs ~450 entries.
-/// We use a larger capacity (1024) to handle longer recordings and
-/// provide a power-of-2 size for efficient modulo operations.
-///
-///
-public sealed class SyncMetricRingBuffer
-{
- private readonly SyncMetricSnapshot[] _buffer;
- private readonly int _capacity;
- private readonly int _mask;
-
- // Write index - only modified by producer
- private long _writeIndex;
-
- ///
- /// Gets the buffer capacity in snapshots.
- ///
- public int Capacity => _capacity;
-
- ///
- /// Gets the total number of snapshots written since creation.
- ///
- public long TotalSnapshotsWritten => Volatile.Read(ref _writeIndex);
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The buffer capacity. Will be rounded up to power of 2.
- public SyncMetricRingBuffer(int capacity = 1024)
- {
- ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(capacity, 0);
-
- _capacity = RoundUpToPowerOfTwo(capacity);
- _mask = _capacity - 1;
- _buffer = new SyncMetricSnapshot[_capacity];
- }
-
- ///
- /// Records a new metric snapshot.
- ///
- /// The snapshot to record.
- public void Record(SyncMetricSnapshot snapshot)
- {
- var writeIdx = Volatile.Read(ref _writeIndex);
- _buffer[writeIdx & _mask] = snapshot;
- Volatile.Write(ref _writeIndex, writeIdx + 1);
- }
-
- ///
- /// Gets all snapshots whose sample positions fall within the specified range.
- ///
- /// The start sample position (inclusive).
- /// The end sample position (exclusive).
- /// Array of snapshots within the range, ordered by sample position.
- public SyncMetricSnapshot[] GetSnapshotsInRange(long startSamplePosition, long endSamplePosition)
- {
- var writeIdx = Volatile.Read(ref _writeIndex);
- var snapshotsAvailable = (int)Math.Min(writeIdx, _capacity);
- var startIdx = writeIdx - snapshotsAvailable;
-
- var results = new List();
-
- for (var i = 0; i < snapshotsAvailable; i++)
- {
- var snapshot = _buffer[(startIdx + i) & _mask];
- if (snapshot.SamplePosition >= startSamplePosition &&
- snapshot.SamplePosition < endSamplePosition)
- {
- results.Add(snapshot);
- }
- }
-
- // Sort by sample position in case of any ordering issues
- results.Sort((a, b) => a.SamplePosition.CompareTo(b.SamplePosition));
-
- return results.ToArray();
- }
-
- ///
- /// Gets all available snapshots.
- ///
- /// Array of all snapshots currently in the buffer.
- public SyncMetricSnapshot[] GetAllSnapshots()
- {
- var writeIdx = Volatile.Read(ref _writeIndex);
- var snapshotsAvailable = (int)Math.Min(writeIdx, _capacity);
- var startIdx = writeIdx - snapshotsAvailable;
-
- var results = new SyncMetricSnapshot[snapshotsAvailable];
-
- for (var i = 0; i < snapshotsAvailable; i++)
- {
- results[i] = _buffer[(startIdx + i) & _mask];
- }
-
- return results;
- }
-
- ///
- /// Resets the buffer to empty state.
- ///
- public void Clear()
- {
- Volatile.Write(ref _writeIndex, 0);
- Array.Clear(_buffer);
- }
-
- ///
- /// Rounds a value up to the next power of 2.
- ///
- private static int RoundUpToPowerOfTwo(int value)
- {
- if (value <= 0)
- {
- return 1;
- }
-
- value--;
- value |= value >> 1;
- value |= value >> 2;
- value |= value >> 4;
- value |= value >> 8;
- value |= value >> 16;
- return value + 1;
- }
-}
diff --git a/src/Sendspin.SDK/Diagnostics/SyncMetricSnapshot.cs b/src/Sendspin.SDK/Diagnostics/SyncMetricSnapshot.cs
deleted file mode 100644
index 304a0a8..0000000
--- a/src/Sendspin.SDK/Diagnostics/SyncMetricSnapshot.cs
+++ /dev/null
@@ -1,104 +0,0 @@
-//
-// Licensed under the MIT License. See LICENSE file in the project root.
-//
-
-using Sendspin.SDK.Audio;
-
-namespace Sendspin.SDK.Diagnostics;
-
-///
-/// Immutable record of sync metrics captured at a specific point in time.
-/// Used for diagnostic audio recording with embedded markers.
-///
-///
-/// Each snapshot represents the sync state at the moment audio samples were captured.
-/// The allows correlation between audio waveform position
-/// and the metrics at that moment.
-///
-public readonly record struct SyncMetricSnapshot
-{
- ///
- /// Gets the local timestamp in microseconds when this snapshot was captured.
- /// Uses for accuracy.
- ///
- public long TimestampMicroseconds { get; init; }
-
- ///
- /// Gets the cumulative sample position in the ring buffer when this snapshot was captured.
- /// Used to correlate markers with audio waveform positions.
- ///
- public long SamplePosition { get; init; }
-
- ///
- /// Gets the raw (unsmoothed) sync error in microseconds at capture time.
- /// Positive = behind (need to speed up), negative = ahead (need to slow down).
- ///
- public long RawSyncErrorMicroseconds { get; init; }
-
- ///
- /// Gets the EMA-smoothed sync error in microseconds at capture time.
- /// This is the value used for correction decisions.
- ///
- public long SmoothedSyncErrorMicroseconds { get; init; }
-
- ///
- /// Gets the current sync correction mode.
- ///
- public SyncCorrectionMode CorrectionMode { get; init; }
-
- ///
- /// Gets the current playback rate (1.0 = normal speed).
- /// Values > 1.0 indicate speedup, < 1.0 indicate slowdown.
- ///
- public double PlaybackRate { get; init; }
-
- ///
- /// Gets the current buffer depth in milliseconds.
- ///
- public double BufferDepthMs { get; init; }
-
- ///
- /// Formats a short label suitable for WAV cue markers.
- ///
- /// A compact string like "ERR:+2.35ms RATE:1.02x RSMP".
- public string FormatShortLabel()
- {
- var modeAbbrev = CorrectionMode switch
- {
- SyncCorrectionMode.Resampling => "RSMP",
- SyncCorrectionMode.Dropping => "DROP",
- SyncCorrectionMode.Inserting => "INS",
- _ => "OK",
- };
-
- var errorMs = SmoothedSyncErrorMicroseconds / 1000.0;
- return $"ERR:{errorMs:+0.00;-0.00}ms RATE:{PlaybackRate:F3}x {modeAbbrev}";
- }
-
- ///
- /// Formats detailed metrics suitable for WAV note chunks or log output.
- ///
- /// A multi-line string with all metric details.
- public string FormatDetailedNote()
- {
- return $"Raw Error: {RawSyncErrorMicroseconds} us\n" +
- $"Smoothed Error: {SmoothedSyncErrorMicroseconds} us\n" +
- $"Playback Rate: {PlaybackRate:F4}x\n" +
- $"Correction Mode: {CorrectionMode}\n" +
- $"Buffer Depth: {BufferDepthMs:F1} ms\n" +
- $"Timestamp: {TimestampMicroseconds} us\n" +
- $"Sample Position: {SamplePosition}";
- }
-
- ///
- /// Formats as a tab-separated line for Audacity label import.
- ///
- /// Start time in seconds (waveform position).
- /// A line like "0.100000\t0.100000\tERR:+1.20ms RATE:1.002x RSMP".
- public string FormatAudacityLabel(double startTimeSeconds)
- {
- // Audacity label format: start_time\tend_time\tlabel
- // Point labels have same start and end time
- return $"{startTimeSeconds:F6}\t{startTimeSeconds:F6}\t{FormatShortLabel()}";
- }
-}
diff --git a/src/Sendspin.SDK/Discovery/DiscoveredServer.cs b/src/Sendspin.SDK/Discovery/DiscoveredServer.cs
deleted file mode 100644
index fc5764e..0000000
--- a/src/Sendspin.SDK/Discovery/DiscoveredServer.cs
+++ /dev/null
@@ -1,77 +0,0 @@
-namespace Sendspin.SDK.Discovery;
-
-///
-/// Represents a Sendspin server discovered via mDNS.
-///
-public sealed class DiscoveredServer
-{
- ///
- /// Unique server identifier from mDNS TXT record.
- ///
- required public string ServerId { get; init; }
-
- ///
- /// Human-readable server name.
- ///
- required public string Name { get; init; }
-
- ///
- /// Server hostname.
- ///
- required public string Host { get; init; }
-
- ///
- /// Server port number.
- ///
- required public int Port { get; init; }
-
- ///
- /// IP addresses for the server.
- ///
- required public IReadOnlyList IpAddresses { get; init; }
-
- ///
- /// Protocol version advertised by the server.
- ///
- public string? ProtocolVersion { get; init; }
-
- ///
- /// Additional properties from TXT records.
- ///
- public IReadOnlyDictionary Properties { get; init; } = new Dictionary();
-
- ///
- /// Time when this server was first discovered.
- ///
- public DateTimeOffset DiscoveredAt { get; init; } = DateTimeOffset.UtcNow;
-
- ///
- /// Time when this server was last seen in discovery.
- ///
- public DateTimeOffset LastSeenAt { get; set; } = DateTimeOffset.UtcNow;
-
- ///
- /// Gets the WebSocket URI for connecting to this server.
- ///
- public Uri GetWebSocketUri()
- {
- // Prefer IP address over hostname for reliability
- var address = IpAddresses.FirstOrDefault() ?? Host;
-
- // Use path from TXT record if available, otherwise default to /sendspin
- var path = Properties.TryGetValue("path", out var p) ? p : "/sendspin";
- if (!path.StartsWith('/'))
- path = "/" + path;
-
- return new Uri($"ws://{address}:{Port}{path}");
- }
-
- public override string ToString() => $"{Name} ({Host}:{Port})";
-
- public override bool Equals(object? obj)
- {
- return obj is DiscoveredServer other && ServerId == other.ServerId;
- }
-
- public override int GetHashCode() => ServerId.GetHashCode();
-}
diff --git a/src/Sendspin.SDK/Discovery/IServerDiscovery.cs b/src/Sendspin.SDK/Discovery/IServerDiscovery.cs
deleted file mode 100644
index 6ba238d..0000000
--- a/src/Sendspin.SDK/Discovery/IServerDiscovery.cs
+++ /dev/null
@@ -1,47 +0,0 @@
-namespace Sendspin.SDK.Discovery;
-
-///
-/// Interface for discovering Sendspin servers on the network.
-///
-public interface IServerDiscovery : IAsyncDisposable
-{
- ///
- /// Currently known servers.
- ///
- IReadOnlyCollection Servers { get; }
-
- ///
- /// Starts continuous discovery.
- ///
- Task StartAsync(CancellationToken cancellationToken = default);
-
- ///
- /// Stops discovery.
- ///
- Task StopAsync();
-
- ///
- /// Performs a one-time scan for servers.
- ///
- /// Scan timeout
- /// Cancellation token
- /// List of discovered servers
- Task> ScanAsync(
- TimeSpan timeout,
- CancellationToken cancellationToken = default);
-
- ///
- /// Event raised when a new server is discovered.
- ///
- event EventHandler? ServerFound;
-
- ///
- /// Event raised when a server is no longer available.
- ///
- event EventHandler? ServerLost;
-
- ///
- /// Event raised when a server's information is updated.
- ///
- event EventHandler? ServerUpdated;
-}
diff --git a/src/Sendspin.SDK/Discovery/MdnsServerDiscovery.cs b/src/Sendspin.SDK/Discovery/MdnsServerDiscovery.cs
deleted file mode 100644
index e73a4f8..0000000
--- a/src/Sendspin.SDK/Discovery/MdnsServerDiscovery.cs
+++ /dev/null
@@ -1,318 +0,0 @@
-using System.Collections.Concurrent;
-using Microsoft.Extensions.Logging;
-using Zeroconf;
-
-namespace Sendspin.SDK.Discovery;
-
-///
-/// Discovers Sendspin servers using mDNS/DNS-SD (Bonjour/Avahi).
-///
-public sealed class MdnsServerDiscovery : IServerDiscovery
-{
- ///
- /// mDNS service type for Sendspin servers (client-initiated connections).
- ///
- public const string ServiceType = "_sendspin-server._tcp.local.";
-
- ///
- /// Alternative service type for server-initiated connections.
- ///
- public const string ClientServiceType = "_sendspin._tcp.local.";
-
- private readonly ILogger _logger;
- private readonly ConcurrentDictionary _servers = new();
- private readonly TimeSpan _serverTimeout = TimeSpan.FromMinutes(2);
-
- private CancellationTokenSource? _discoveryCts;
- private Task? _discoveryTask;
- private Timer? _cleanupTimer;
- private bool _disposed;
-
- public IReadOnlyCollection Servers => _servers.Values.ToList();
-
- ///
- /// Gets whether discovery is currently running.
- ///
- public bool IsDiscovering => _discoveryCts is not null;
-
- public event EventHandler? ServerFound;
- public event EventHandler? ServerLost;
- public event EventHandler? ServerUpdated;
-
- public MdnsServerDiscovery(ILogger logger)
- {
- _logger = logger;
- }
-
- public Task StartAsync(CancellationToken cancellationToken = default)
- {
- if (_discoveryCts is not null)
- {
- return Task.CompletedTask; // Already running
- }
-
- _logger.LogInformation("Starting mDNS discovery for {ServiceType}", ServiceType);
-
- _discoveryCts = new CancellationTokenSource();
- _discoveryTask = ContinuousDiscoveryAsync(_discoveryCts.Token);
-
- // Start cleanup timer to remove stale servers
- _cleanupTimer = new Timer(CleanupStaleServers, null,
- TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30));
-
- return Task.CompletedTask;
- }
-
- public async Task StopAsync()
- {
- _logger.LogInformation("Stopping mDNS discovery");
-
- _cleanupTimer?.Dispose();
- _cleanupTimer = null;
-
- if (_discoveryCts is not null)
- {
- await _discoveryCts.CancelAsync();
-
- if (_discoveryTask is not null)
- {
- try
- {
- await _discoveryTask;
- }
- catch (OperationCanceledException)
- {
- // Expected
- }
- }
-
- _discoveryCts.Dispose();
- _discoveryCts = null;
- _discoveryTask = null;
- }
- }
-
- public async Task> ScanAsync(
- TimeSpan timeout,
- CancellationToken cancellationToken = default)
- {
- _logger.LogDebug("Scanning for Sendspin servers (timeout: {Timeout})", timeout);
-
- try
- {
- var results = await ZeroconfResolver.ResolveAsync(
- ServiceType,
- scanTime: timeout,
- cancellationToken: cancellationToken);
-
- var servers = new List();
-
- foreach (var host in results)
- {
- var server = ParseHost(host);
- if (server is not null)
- {
- servers.Add(server);
- UpdateServer(server);
- }
- }
-
- _logger.LogInformation("Scan found {Count} server(s)", servers.Count);
- return servers;
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error during mDNS scan");
- return Array.Empty();
- }
- }
-
- private async Task ContinuousDiscoveryAsync(CancellationToken cancellationToken)
- {
- while (!cancellationToken.IsCancellationRequested)
- {
- try
- {
- // Perform a scan
- await ScanAsync(TimeSpan.FromSeconds(5), cancellationToken);
-
- // Wait before next scan
- await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken);
- }
- catch (OperationCanceledException)
- {
- break;
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Error in continuous discovery, retrying...");
- await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
- }
- }
- }
-
- private DiscoveredServer? ParseHost(IZeroconfHost host)
- {
- try
- {
- var service = host.Services.Values.FirstOrDefault();
- if (service is null)
- {
- return null;
- }
-
- // Log the raw host info for debugging
- _logger.LogInformation("mDNS Host: {DisplayName}, IPs: [{IPs}], Port: {Port}",
- host.DisplayName,
- string.Join(", ", host.IPAddresses),
- service.Port);
-
- // Extract TXT record properties
- var properties = new Dictionary();
- foreach (var prop in service.Properties)
- {
- foreach (var kvp in prop)
- {
- properties[kvp.Key] = kvp.Value;
- _logger.LogInformation("mDNS TXT: {Key} = {Value}", kvp.Key, kvp.Value);
- }
- }
-
- if (properties.Count == 0)
- {
- _logger.LogWarning("No TXT records found for host {Host}", host.DisplayName);
- }
-
- // Get server ID from TXT record or generate from name
- var serverId = properties.TryGetValue("id", out var id)
- ? id
- : properties.TryGetValue("server_id", out var sid)
- ? sid
- : $"{host.DisplayName}-{host.IPAddresses.FirstOrDefault()}";
-
- // Try multiple common TXT record keys for friendly name
- var friendlyName = GetFriendlyName(properties, host.DisplayName);
-
- var server = new DiscoveredServer
- {
- ServerId = serverId,
- Name = friendlyName,
- Host = host.DisplayName,
- Port = service.Port,
- IpAddresses = host.IPAddresses.ToList(),
- ProtocolVersion = properties.TryGetValue("version", out var version) ? version : null,
- Properties = properties
- };
-
- return server;
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Failed to parse host {Host}", host.DisplayName);
- return null;
- }
- }
-
- ///
- /// Extracts a user-friendly name from TXT records, with smart fallback to hostname.
- ///
- private static string GetFriendlyName(Dictionary properties, string? hostDisplayName)
- {
- // Try common TXT record keys for friendly name (in priority order)
- string[] nameKeys = ["name", "friendly_name", "fn", "server_name", "display_name"];
-
- foreach (var key in nameKeys)
- {
- if (properties.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
- {
- return value;
- }
- }
-
- // If hostname is null or empty, return a sensible default
- if (string.IsNullOrWhiteSpace(hostDisplayName))
- {
- return "Unknown Server";
- }
-
- // Fallback: clean up the hostname to make it more presentable
- // e.g., "homeassistant.local" → "Homeassistant"
- // e.g., "music-assistant-server.local" → "Music Assistant Server"
- var cleanName = hostDisplayName;
-
- // Remove common suffixes
- string[] suffixes = [".local", ".lan", ".home"];
- foreach (var suffix in suffixes)
- {
- if (cleanName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
- {
- cleanName = cleanName[..^suffix.Length];
- break;
- }
- }
-
- // Replace separators with spaces
- cleanName = cleanName.Replace('-', ' ').Replace('_', ' ');
-
- // Title case each word
- if (!string.IsNullOrEmpty(cleanName))
- {
- var words = cleanName.Split(' ', StringSplitOptions.RemoveEmptyEntries);
- for (int i = 0; i < words.Length; i++)
- {
- if (words[i].Length > 0)
- {
- words[i] = char.ToUpperInvariant(words[i][0]) + words[i][1..].ToLowerInvariant();
- }
- }
-
- cleanName = string.Join(' ', words);
- }
-
- return string.IsNullOrWhiteSpace(cleanName) ? hostDisplayName : cleanName;
- }
-
- private void UpdateServer(DiscoveredServer server)
- {
- if (_servers.TryGetValue(server.ServerId, out var existing))
- {
- // Update last seen time
- existing.LastSeenAt = DateTimeOffset.UtcNow;
- ServerUpdated?.Invoke(this, existing);
- }
- else
- {
- // New server
- if (_servers.TryAdd(server.ServerId, server))
- {
- _logger.LogInformation("Discovered new server: {Server}", server);
- ServerFound?.Invoke(this, server);
- }
- }
- }
-
- private void CleanupStaleServers(object? state)
- {
- var cutoff = DateTimeOffset.UtcNow - _serverTimeout;
- var staleServers = _servers.Values
- .Where(s => s.LastSeenAt < cutoff)
- .ToList();
-
- foreach (var server in staleServers)
- {
- if (_servers.TryRemove(server.ServerId, out _))
- {
- _logger.LogInformation("Server lost (timeout): {Server}", server);
- ServerLost?.Invoke(this, server);
- }
- }
- }
-
- public async ValueTask DisposeAsync()
- {
- if (_disposed) return;
- _disposed = true;
-
- await StopAsync();
- }
-}
diff --git a/src/Sendspin.SDK/Discovery/MdnsServiceAdvertiser.cs b/src/Sendspin.SDK/Discovery/MdnsServiceAdvertiser.cs
deleted file mode 100644
index 2238dff..0000000
--- a/src/Sendspin.SDK/Discovery/MdnsServiceAdvertiser.cs
+++ /dev/null
@@ -1,272 +0,0 @@
-using System.Net;
-using System.Net.NetworkInformation;
-using System.Net.Sockets;
-using Makaretu.Dns;
-using Microsoft.Extensions.Logging;
-
-namespace Sendspin.SDK.Discovery;
-
-///
-/// Advertises this client as a Sendspin service via mDNS.
-/// This enables server-initiated connections where Sendspin servers
-/// discover and connect to this client.
-///
-public sealed class MdnsServiceAdvertiser : IAsyncDisposable
-{
- private readonly ILogger _logger;
- private readonly AdvertiserOptions _options;
- private MulticastService? _mdns;
- private ServiceDiscovery? _serviceDiscovery;
- private ServiceProfile? _serviceProfile;
- private bool _disposed;
-
- ///
- /// Whether the service is currently being advertised.
- ///
- public bool IsAdvertising { get; private set; }
-
- ///
- /// The client ID being advertised.
- ///
- public string ClientId => _options.ClientId;
-
- public MdnsServiceAdvertiser(ILogger logger, AdvertiserOptions? options = null)
- {
- _logger = logger;
- _options = options ?? new AdvertiserOptions();
- }
-
- ///
- /// Starts advertising this client as a Sendspin service.
- ///
- public Task StartAsync(CancellationToken cancellationToken = default)
- {
- ObjectDisposedException.ThrowIf(_disposed, this);
-
- if (IsAdvertising)
- {
- _logger.LogWarning("Already advertising");
- return Task.CompletedTask;
- }
-
- try
- {
- // Create the multicast DNS service
- _mdns = new MulticastService();
-
- // Log network interfaces being used
- _mdns.NetworkInterfaceDiscovered += (s, e) =>
- {
- foreach (var nic in e.NetworkInterfaces)
- {
- _logger.LogDebug("mDNS using network interface: {Name} ({Id})",
- nic.Name, nic.Id);
- }
- };
-
- // Log when queries are received (helps debug if mDNS is working)
- var queryCount = 0;
- _mdns.QueryReceived += (s, e) =>
- {
- foreach (var q in e.Message.Questions)
- {
- // Log sendspin queries with high priority
- if (q.Name.ToString().Contains("sendspin", StringComparison.OrdinalIgnoreCase))
- {
- _logger.LogInformation("*** Received mDNS query for SENDSPIN: {Name} (type={Type})",
- q.Name, q.Type);
- }
- // Log first few queries to verify mDNS is working
- else if (queryCount < 5)
- {
- _logger.LogDebug("Received mDNS query: {Name} (type={Type})",
- q.Name, q.Type);
- queryCount++;
- }
- }
- };
-
- // Create service discovery for advertising
- _serviceDiscovery = new ServiceDiscovery(_mdns);
-
- // Get local IP addresses - filter out link-local addresses
- var addresses = GetLocalIPAddresses()
- .Where(ip => !ip.ToString().StartsWith("169.254.")) // Skip APIPA
- .ToList();
-
- _logger.LogInformation("Local IP addresses for mDNS: {Addresses}",
- string.Join(", ", addresses));
-
- if (addresses.Count == 0)
- {
- throw new InvalidOperationException("No valid network addresses found for mDNS advertising");
- }
-
- // Create the service profile
- // Service type: _sendspin._tcp.local.
- // Instance name: client ID
- _serviceProfile = new ServiceProfile(
- instanceName: _options.ClientId,
- serviceName: "_sendspin._tcp",
- port: (ushort)_options.Port,
- addresses: addresses);
-
- // Add TXT records - path must start with /
- _serviceProfile.AddProperty("path", _options.Path);
- // TODO: Re-enable once "name" TXT record is in official spec
- // _serviceProfile.AddProperty("name", _options.PlayerName);
-
- // Log the service profile details
- _logger.LogInformation(
- "mDNS Service Profile: FullName={FullName}, ServiceName={Service}, HostName={Host}, Port={Port}",
- _serviceProfile.FullyQualifiedName,
- _serviceProfile.ServiceName,
- _serviceProfile.HostName,
- _options.Port);
-
- // Log all resources being advertised
- foreach (var resource in _serviceProfile.Resources)
- {
- _logger.LogDebug("mDNS Resource: {Type} {Name}",
- resource.GetType().Name, resource.Name);
- }
-
- // Advertise the service
- _serviceDiscovery.Advertise(_serviceProfile);
-
- // Start the multicast service
- _mdns.Start();
-
- IsAdvertising = true;
- _logger.LogInformation(
- "Advertising Sendspin client: {ClientId} on port {Port} (path={Path})",
- _options.ClientId, _options.Port, _options.Path);
-
- return Task.CompletedTask;
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to start mDNS advertising");
- throw;
- }
- }
-
- ///
- /// Stops advertising the service.
- ///
- public Task StopAsync()
- {
- if (!IsAdvertising)
- return Task.CompletedTask;
-
- _logger.LogInformation("Stopping mDNS advertisement for {ClientId}", _options.ClientId);
-
- try
- {
- if (_serviceProfile != null && _serviceDiscovery != null)
- {
- _serviceDiscovery.Unadvertise(_serviceProfile);
- }
-
- _mdns?.Stop();
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Error stopping mDNS service");
- }
- finally
- {
- _serviceDiscovery = null;
- _serviceProfile = null;
- _mdns?.Dispose();
- _mdns = null;
- IsAdvertising = false;
- }
-
- return Task.CompletedTask;
- }
-
- ///
- /// Gets local IPv4 addresses for this machine, preferring interfaces with a default gateway.
- /// This filters out virtual adapters (Hyper-V, WSL, Docker) that aren't reachable from the LAN.
- ///
- private IEnumerable GetLocalIPAddresses()
- {
- var gatewayAddresses = new List();
- var allAddresses = new List();
-
- foreach (var ni in NetworkInterface.GetAllNetworkInterfaces())
- {
- if (ni.OperationalStatus != OperationalStatus.Up)
- continue;
-
- if (ni.NetworkInterfaceType == NetworkInterfaceType.Loopback)
- continue;
-
- var props = ni.GetIPProperties();
- var hasGateway = props.GatewayAddresses
- .Any(g => g.Address.AddressFamily == AddressFamily.InterNetwork
- && !g.Address.Equals(IPAddress.Any));
-
- foreach (var addr in props.UnicastAddresses)
- {
- if (addr.Address.AddressFamily == AddressFamily.InterNetwork)
- {
- allAddresses.Add(addr.Address);
- if (hasGateway)
- {
- gatewayAddresses.Add(addr.Address);
- }
- }
- }
- }
-
- // Prefer interfaces with a gateway (connected to a real network).
- // Fall back to all addresses only if no gateway interfaces exist.
- var result = gatewayAddresses.Count > 0 ? gatewayAddresses : allAddresses;
- foreach (var addr in result)
- {
- yield return addr;
- }
- }
-
- public async ValueTask DisposeAsync()
- {
- if (_disposed) return;
- _disposed = true;
-
- await StopAsync();
- }
-}
-
-///
-/// Configuration options for mDNS service advertising.
-///
-public sealed class AdvertiserOptions
-{
- ///
- /// Unique client identifier.
- /// Default: sendspin-windows-{hostname}
- ///
- public string ClientId { get; set; } = $"sendspin-windows-{Environment.MachineName.ToLowerInvariant()}";
-
- ///
- /// Human-readable player name (advertised in TXT record as "name").
- /// Allows servers to display a friendly name during mDNS discovery,
- /// before the WebSocket handshake occurs.
- /// Default: machine name
- ///
- public string PlayerName { get; set; } = Environment.MachineName;
-
- ///
- /// Port the WebSocket server is listening on.
- /// Default: 8928
- ///
- public int Port { get; set; } = 8928;
-
- ///
- /// WebSocket endpoint path (advertised in TXT record).
- /// Default: /sendspin
- ///
- public string Path { get; set; } = "/sendspin";
-}
diff --git a/src/Sendspin.SDK/Extensions/TaskExtensions.cs b/src/Sendspin.SDK/Extensions/TaskExtensions.cs
deleted file mode 100644
index 987982f..0000000
--- a/src/Sendspin.SDK/Extensions/TaskExtensions.cs
+++ /dev/null
@@ -1,78 +0,0 @@
-//
-// Licensed under the MIT License. See LICENSE file in the project root.
-//
-
-using System.Runtime.CompilerServices;
-using Microsoft.Extensions.Logging;
-
-namespace Sendspin.SDK.Extensions;
-
-///
-/// Extension methods for to handle common async patterns safely.
-///
-public static class TaskExtensions
-{
- ///
- /// Executes a task in a fire-and-forget manner while properly handling exceptions.
- ///
- ///
- ///
- /// Use this instead of _ = SomeAsync() which swallows exceptions silently.
- /// This method ensures exceptions are logged rather than lost.
- ///
- ///
- /// Example usage:
- ///
- /// // Instead of: _ = DoSomethingAsync();
- /// DoSomethingAsync().SafeFireAndForget(_logger);
- ///
- ///
- ///
- /// The task to execute.
- /// Optional logger for error reporting. If null, exceptions are silently observed.
- /// Automatically captured caller member name for diagnostics.
- public static async void SafeFireAndForget(
- this Task task,
- ILogger? logger = null,
- [CallerMemberName] string? caller = null)
- {
- try
- {
- await task.ConfigureAwait(false);
- }
- catch (OperationCanceledException)
- {
- // Cancellation is expected and should not be logged as an error
- logger?.LogDebug("Fire-and-forget task cancelled in {Caller}", caller);
- }
- catch (Exception ex)
- {
- logger?.LogError(ex, "Fire-and-forget task failed in {Caller}", caller);
- }
- }
-
- ///
- /// Executes a task in a fire-and-forget manner with a custom error handler.
- ///
- /// The task to execute.
- /// Action to invoke when an exception occurs.
- /// Automatically captured caller member name for diagnostics.
- public static async void SafeFireAndForget(
- this Task task,
- Action onError,
- [CallerMemberName] string? caller = null)
- {
- try
- {
- await task.ConfigureAwait(false);
- }
- catch (OperationCanceledException)
- {
- // Cancellation is expected - no action needed
- }
- catch (Exception ex)
- {
- onError?.Invoke(ex);
- }
- }
-}
diff --git a/src/Sendspin.SDK/MIGRATION-5.4.0.md b/src/Sendspin.SDK/MIGRATION-5.4.0.md
deleted file mode 100644
index 368d046..0000000
--- a/src/Sendspin.SDK/MIGRATION-5.4.0.md
+++ /dev/null
@@ -1,213 +0,0 @@
-# Sendspin.SDK 5.4.0 Migration Guide
-
-## Overview
-
-Version 5.4.0 introduces proper separation between **player volume** (this client's actual volume) and **group volume** (the average displayed to controllers). This fixes group volume control issues where the Windows client wasn't responding correctly to server commands.
-
----
-
-## Breaking Changes
-
-### 1. `GroupStateChanged` No Longer Contains Player Volume
-
-**Before (5.3.x)**: `GroupState.Volume` and `GroupState.Muted` represented this player's current state.
-
-**After (5.4.0)**: `GroupState.Volume` and `GroupState.Muted` now represent the **group average** (for display only). Your player's actual volume is in the new `PlayerStateChanged` event.
-
-**Migration**:
-```csharp
-// ❌ OLD - Don't use GroupState for player volume anymore
-client.GroupStateChanged += (s, group) => {
- myVolumeSlider.Value = group.Volume; // Wrong! This is group average
- myMuteButton.IsChecked = group.Muted; // Wrong! This is group muted
-};
-
-// ✅ NEW - Use PlayerStateChanged for player volume
-client.PlayerStateChanged += (s, playerState) => {
- myVolumeSlider.Value = playerState.Volume; // Correct!
- myMuteButton.IsChecked = playerState.Muted; // Correct!
-};
-
-// GroupState is still used for playback state, metadata, etc.
-client.GroupStateChanged += (s, group) => {
- myPlaybackState = group.PlaybackState; // Still correct
- myTrackTitle.Text = group.Metadata?.Title; // Still correct
-};
-```
-
----
-
-## New Features
-
-### 2. New `PlayerState` Model
-
-A new model class to represent this player's volume and mute state:
-
-```csharp
-namespace Sendspin.SDK.Models;
-
-public sealed class PlayerState
-{
- public int Volume { get; set; } = 100; // 0-100
- public bool Muted { get; set; }
-}
-```
-
-### 3. New `PlayerStateChanged` Event
-
-Fires when the server sends a `server/command` to change this player's volume or mute state:
-
-```csharp
-public interface ISendSpinClient
-{
- // NEW in 5.4.0
- event EventHandler? PlayerStateChanged;
- PlayerState CurrentPlayerState { get; }
-
- // Existing (unchanged)
- event EventHandler? GroupStateChanged;
- GroupState? CurrentGroup { get; }
-}
-```
-
-### 4. Automatic ACK After `server/command`
-
-The SDK now automatically sends a `client/state` acknowledgement when it receives and applies a `server/command` for volume/mute. **You don't need to do anything** - this happens internally.
-
-This fixes the spec compliance issue where the server didn't know the player applied the change.
-
-### 5. `ClientCapabilities.InitialVolume` and `InitialMuted`
-
-New properties to set the player's initial volume/mute when connecting:
-
-```csharp
-var capabilities = new ClientCapabilities
-{
- ClientName = "My Player",
- InitialVolume = 75, // NEW - Start at 75% volume
- InitialMuted = false // NEW - Start unmuted
-};
-
-var client = new SendspinClientService(logger, connection,
- clockSync, capabilities, pipeline);
-```
-
-These values are:
-1. Sent to the server in the initial `client/state` handshake
-2. Applied to the audio pipeline on connection
-3. Used to initialize `CurrentPlayerState`
-
----
-
-## Recommended Implementation Pattern
-
-```csharp
-public class MyPlayer
-{
- private SendspinClientService _client;
- private int _playerVolume = 100;
- private bool _isPlayerMuted = false;
- private bool _isUpdatingFromServer = false;
-
- public void Initialize()
- {
- // Load persisted volume from settings
- _playerVolume = LoadVolumeFromSettings();
- _isPlayerMuted = LoadMutedFromSettings();
-
- var capabilities = new ClientCapabilities
- {
- InitialVolume = _playerVolume,
- InitialMuted = _isPlayerMuted
- };
-
- _client = new SendspinClientService(logger, connection,
- clockSync, capabilities, pipeline);
-
- // Subscribe to PLAYER state (your volume)
- _client.PlayerStateChanged += OnPlayerStateChanged;
-
- // Subscribe to GROUP state (playback info, metadata)
- _client.GroupStateChanged += OnGroupStateChanged;
- }
-
- private void OnPlayerStateChanged(object? sender, PlayerState state)
- {
- // Server changed our volume via controller
- _isUpdatingFromServer = true;
- try
- {
- _playerVolume = state.Volume;
- _isPlayerMuted = state.Muted;
- UpdateUI();
-
- // Optionally persist the new values
- SaveVolumeToSettings(state.Volume);
- SaveMutedToSettings(state.Muted);
- }
- finally
- {
- _isUpdatingFromServer = false;
- }
- }
-
- private void OnGroupStateChanged(object? sender, GroupState group)
- {
- // Update playback state, track info, etc.
- // Do NOT update volume/mute from here
- UpdatePlaybackState(group.PlaybackState);
- UpdateTrackMetadata(group.Metadata);
- }
-
- public void OnUserChangedVolume(int newVolume)
- {
- if (_isUpdatingFromServer) return; // Avoid feedback loop
-
- _playerVolume = newVolume;
-
- // Apply to audio immediately
- _client.SetVolume(newVolume);
-
- // Notify server
- _ = _client.SendPlayerStateAsync(newVolume, _isPlayerMuted);
-
- // Persist
- SaveVolumeToSettings(newVolume);
- }
-}
-```
-
----
-
-## Audio Volume Curve (Optional Enhancement)
-
-The SDK doesn't enforce a volume curve, but for perceived loudness matching the reference CLI, apply a **power curve** in your audio output:
-
-```csharp
-// In your audio sample provider
-float amplitude = (float)Math.Pow(volume / 100.0, 1.5);
-sample *= amplitude;
-```
-
-| Linear Volume | Power Curve Amplitude | Perceived Effect |
-|---------------|----------------------|------------------|
-| 100% | 1.0 | Full volume |
-| 50% | 0.35 | Half perceived loudness |
-| 25% | 0.125 | Quarter perceived loudness |
-| 10% | 0.03 | Very quiet |
-
----
-
-## Summary Checklist
-
-- [ ] Subscribe to `PlayerStateChanged` for volume/mute updates from server
-- [ ] Stop reading `Volume`/`Muted` from `GroupStateChanged`
-- [ ] Set `ClientCapabilities.InitialVolume`/`InitialMuted` for persistence
-- [ ] Use `_isUpdatingFromServer` flag to prevent feedback loops
-- [ ] (Optional) Apply power curve `amplitude = volume^1.5` for perceived loudness
-
----
-
-## Why This Matters for Groups
-
-When you have players at different volumes (e.g., 15% and 45%), the server calculates a group average (30%). Before 5.4.0, Windows would incorrectly apply this 30% to its own audio. Now it correctly ignores the group average and only responds to explicit `server/command` messages that target this specific player.
diff --git a/src/Sendspin.SDK/MIGRATION-6.0.0.md b/src/Sendspin.SDK/MIGRATION-6.0.0.md
deleted file mode 100644
index 790dc9c..0000000
--- a/src/Sendspin.SDK/MIGRATION-6.0.0.md
+++ /dev/null
@@ -1,352 +0,0 @@
-# Sendspin.SDK 6.0.0 Migration Guide
-
-## Overview
-
-Version 6.0.0 brings the SDK into alignment with the official [Sendspin protocol specification](https://www.sendspin-audio.com/spec/). This release removes non-spec extensions, adds missing spec fields, and fixes naming mismatches.
-
-**Why this matters**: Strict spec compliance ensures compatibility with all Sendspin servers, not just aiosendspin. Non-spec fields were removed because they created false expectations about what data `group/update` provides.
-
----
-
-## Breaking Changes Summary
-
-| Area | Change | Impact |
-|------|--------|--------|
-| `GroupUpdatePayload` | Removed 6 fields | Medium - Use `GroupState` instead |
-| `TrackMetadata` | Restructured, Duration/Position read-only | Medium - Use `Progress` object |
-| `ServerHelloPayload` | Removed 3 fields, version type changed | Low - Rarely accessed directly |
-| `StreamStartPayload` | Removed 2 fields | Low - Internal use only |
-| `ClientHelloPayload` | Property name fixed | None - Internal JSON change |
-
----
-
-## 1. GroupUpdatePayload Changes
-
-### What Changed
-
-The `GroupUpdatePayload` class now only contains the three fields defined in the spec:
-
-```csharp
-// ✅ KEPT (in spec)
-public string GroupId { get; set; }
-public string? GroupName { get; set; }
-public PlaybackState? PlaybackState { get; set; }
-
-// ❌ REMOVED (not in spec)
-// public int Volume { get; set; }
-// public bool Muted { get; set; }
-// public TrackMetadata? Metadata { get; set; }
-// public double Position { get; set; }
-// public bool Shuffle { get; set; }
-// public string? Repeat { get; set; }
-```
-
-### Why This Changed
-
-Per the Sendspin spec, `group/update` only carries playback state and identity:
-- **Volume/Muted** come from `server/state` controller object
-- **Metadata/Shuffle/Repeat** come from `server/state` metadata object
-
-The SDK was providing these fields as "convenience" accessors, but they were always populated from `server/state`, not `group/update`. This was misleading.
-
-### Migration
-
-If you were accessing `GroupUpdatePayload` directly (rare), use `GroupState` instead:
-
-```csharp
-// ❌ OLD - Direct payload access (rare pattern)
-client.MessageReceived += (s, msg) => {
- if (msg is GroupUpdateMessage update) {
- var volume = update.Payload.Volume; // COMPILE ERROR
- }
-};
-
-// ✅ NEW - Use GroupState (recommended pattern)
-client.GroupStateChanged += (s, group) => {
- var volume = group.Volume; // From server/state controller
- var metadata = group.Metadata; // From server/state metadata
- var state = group.PlaybackState; // From group/update
-};
-```
-
-**Most apps already use `GroupStateChanged` and require no changes.**
-
----
-
-## 2. TrackMetadata Changes
-
-### What Changed
-
-The `TrackMetadata` class has been restructured to match the spec:
-
-```csharp
-// ✅ KEPT
-public string? Title { get; set; }
-public string? Artist { get; set; }
-public string? Album { get; set; }
-public string? ArtworkUrl { get; set; }
-
-// ✅ NEW (added from spec)
-public long? Timestamp { get; set; } // Server timestamp (microseconds)
-public string? AlbumArtist { get; set; } // May differ from Artist on compilations
-public int? Year { get; set; } // Release year
-public int? Track { get; set; } // Track number on album
-public PlaybackProgress? Progress { get; set; } // Duration/position container
-public string? Repeat { get; set; } // "off", "one", "all"
-public bool? Shuffle { get; set; }
-
-// ⚠️ CHANGED - Now read-only computed properties
-public double? Duration { get; } // Computed from Progress?.TrackDuration / 1000.0
-public double? Position { get; } // Computed from Progress?.TrackProgress / 1000.0
-
-// ❌ REMOVED (not in spec)
-// public string? ArtworkUri { get; set; } // Use ArtworkUrl
-// public string? Uri { get; set; }
-// public string? MediaType { get; set; }
-```
-
-### The Progress Object
-
-Duration and position are now nested in a `PlaybackProgress` object (per spec):
-
-```csharp
-public sealed class PlaybackProgress
-{
- [JsonPropertyName("track_progress")]
- public long TrackProgress { get; set; } // Milliseconds
-
- [JsonPropertyName("track_duration")]
- public long TrackDuration { get; set; } // Milliseconds
-
- [JsonPropertyName("playback_speed")]
- public int PlaybackSpeed { get; set; } // × 1000 (1000 = normal speed)
-}
-```
-
-### Migration
-
-**Reading Duration/Position** - No changes needed! Computed properties handle this:
-
-```csharp
-// ✅ Still works - computed properties
-var duration = metadata.Duration; // Returns Progress?.TrackDuration / 1000.0
-var position = metadata.Position; // Returns Progress?.TrackProgress / 1000.0
-```
-
-**Setting Duration/Position** - If you were setting these (unlikely), set `Progress` instead:
-
-```csharp
-// ❌ OLD - Won't compile (now read-only)
-metadata.Duration = 180.5;
-metadata.Position = 45.2;
-
-// ✅ NEW - Set Progress object
-metadata.Progress = new PlaybackProgress
-{
- TrackDuration = 180500, // Milliseconds
- TrackProgress = 45200 // Milliseconds
-};
-```
-
-**Using Uri** - If you used `Uri` for track identification, use a composite key:
-
-```csharp
-// ❌ OLD - Uri property removed
-var trackId = metadata.Uri;
-
-// ✅ NEW - Use composite key
-var trackId = $"{metadata.Title}|{metadata.Artist}|{metadata.Album}";
-```
-
-**Using ArtworkUri** - Use `ArtworkUrl` instead (same value, spec-compliant name):
-
-```csharp
-// ❌ OLD
-var artworkUrl = metadata.ArtworkUri;
-
-// ✅ NEW
-var artworkUrl = metadata.ArtworkUrl;
-```
-
----
-
-## 3. ServerHelloPayload Changes
-
-### What Changed
-
-```csharp
-// ✅ KEPT
-public string ServerId { get; set; }
-public string? Name { get; set; }
-public List ActiveRoles { get; set; }
-public string? ConnectionReason { get; set; }
-
-// ⚠️ CHANGED - Type changed from string to int
-public int Version { get; set; } // Was: public string ProtocolVersion
-
-// ❌ REMOVED (not in spec)
-// public string? GroupId { get; set; }
-// public Dictionary? Support { get; set; }
-```
-
-### Migration
-
-```csharp
-// ❌ OLD
-var protoVersion = serverHello.Payload.ProtocolVersion; // string
-var groupId = serverHello.Payload.GroupId;
-
-// ✅ NEW
-var version = serverHello.Payload.Version; // int (always 1)
-// GroupId is not available from server/hello - use group/update instead
-```
-
-**Most apps don't access ServerHelloPayload directly and require no changes.**
-
----
-
-## 4. StreamStartPayload Changes
-
-### What Changed
-
-```csharp
-// ✅ KEPT
-public AudioFormat Format { get; set; } // codec, sample_rate, channels, etc.
-
-// ❌ REMOVED (not in spec)
-// public string? StreamId { get; set; }
-// public long TargetTimestamp { get; set; }
-```
-
-### Migration
-
-These fields were SDK-internal and not exposed via events. **No app-level changes needed.**
-
----
-
-## 5. ClientHelloPayload Changes
-
-### What Changed
-
-The JSON property name for player support was corrected:
-
-```csharp
-// ❌ OLD (wrong JSON name)
-[JsonPropertyName("player_support")]
-public PlayerSupport? PlayerV1Support { get; init; }
-
-// ✅ NEW (spec-compliant JSON name)
-[JsonPropertyName("player@v1_support")]
-public PlayerSupport? PlayerV1Support { get; init; }
-```
-
-### Migration
-
-**No app-level changes needed.** This is a wire-protocol fix - the C# property name is unchanged.
-
-> **Note**: aiosendspin accepts both names for backward compatibility, but other spec-compliant servers may require the correct name.
-
----
-
-## New Features
-
-### New TrackMetadata Fields
-
-Take advantage of the new spec-compliant fields:
-
-```csharp
-client.GroupStateChanged += (s, group) => {
- var meta = group.Metadata;
- if (meta == null) return;
-
- // NEW - Available in 6.0.0
- Console.WriteLine($"Album Artist: {meta.AlbumArtist}");
- Console.WriteLine($"Year: {meta.Year}");
- Console.WriteLine($"Track #: {meta.Track}");
- Console.WriteLine($"Timestamp: {meta.Timestamp}");
-
- // NEW - Playback speed (for variable-speed playback)
- if (meta.Progress?.PlaybackSpeed is int speed)
- {
- var multiplier = speed / 1000.0; // 1000 = 1.0x
- Console.WriteLine($"Playback Speed: {multiplier:F2}x");
- }
-};
-```
-
-### Improved Documentation
-
-All models now have comprehensive XML documentation explaining:
-- Which protocol message populates each field
-- Expected value ranges and formats
-- SDK extensions vs spec-compliant fields
-
-```csharp
-///
-/// Aggregate state for display purposes. Populated from multiple message types.
-///
-///
-/// Field sources per Sendspin spec:
-///
-/// - GroupId, Name, PlaybackState - from group/update
-/// - Volume, Muted - from server/state controller object
-/// - Metadata, Shuffle, Repeat - from server/state metadata object
-///
-///
-public sealed class GroupState { ... }
-```
-
----
-
-## Retained SDK Extensions
-
-These non-spec features are intentionally kept and documented:
-
-| Extension | Purpose | Location |
-|-----------|---------|----------|
-| `client/sync_offset` | GroupSync acoustic calibration | Protocol message |
-| `client/sync_offset_ack` | ACK for sync offset | Protocol message |
-| `PlayerStatePayload.BufferLevel` | Diagnostic buffer reporting | ClientStateMessage |
-| `PlayerStatePayload.Error` | Error message reporting | ClientStateMessage |
-
-These are marked in XML docs as "SDK extension (not part of Sendspin spec)".
-
----
-
-## Migration Checklist
-
-### Required Changes
-
-- [ ] If setting `TrackMetadata.Duration`/`.Position` directly → Set `Progress` object instead
-- [ ] If using `TrackMetadata.Uri` → Use composite key (title|artist|album)
-- [ ] If using `TrackMetadata.ArtworkUri` → Use `ArtworkUrl`
-- [ ] If accessing `ServerHelloPayload.ProtocolVersion` → Use `Version` (int)
-- [ ] If accessing `GroupUpdatePayload.Volume`/`.Muted`/etc. → Use `GroupState`
-
-### No Changes Needed If
-
-- [x] You use `GroupStateChanged` event (most apps)
-- [x] You only read `TrackMetadata.Duration`/`.Position` (computed properties work)
-- [x] You use `TrackMetadata.ArtworkUrl` (unchanged)
-- [x] You don't access protocol payloads directly
-
----
-
-## Compatibility Notes
-
-### aiosendspin Compatibility
-
-The aiosendspin server accepts both old and new JSON property names via aliases. Your upgraded SDK will work with existing aiosendspin servers.
-
-### Other Sendspin Servers
-
-Spec-compliant servers that don't have legacy aliases will now work correctly with the SDK. The `player@v1_support` fix in particular ensures proper handshake with strict servers.
-
----
-
-## Questions?
-
-If you encounter issues migrating, check:
-1. The SDK XML documentation on each class
-2. The [Sendspin spec](https://www.sendspin-audio.com/spec/)
-3. The [SDK release notes](https://www.nuget.org/packages/Sendspin.SDK) on NuGet
diff --git a/src/Sendspin.SDK/Models/AudioFormat.cs b/src/Sendspin.SDK/Models/AudioFormat.cs
deleted file mode 100644
index 582e5b0..0000000
--- a/src/Sendspin.SDK/Models/AudioFormat.cs
+++ /dev/null
@@ -1,62 +0,0 @@
-using System.Text.Json.Serialization;
-
-namespace Sendspin.SDK.Models;
-
-///
-/// Represents an audio format specification.
-///
-public sealed class AudioFormat
-{
- ///
- /// Audio codec (e.g., "opus", "flac", "pcm").
- ///
- [JsonPropertyName("codec")]
- public string Codec { get; set; } = "opus";
-
- ///
- /// Sample rate in Hz (e.g., 44100, 48000).
- ///
- [JsonPropertyName("sample_rate")]
- public int SampleRate { get; set; } = 48000;
-
- ///
- /// Number of audio channels (1 = mono, 2 = stereo).
- ///
- [JsonPropertyName("channels")]
- public int Channels { get; set; } = 2;
-
- ///
- /// Bits per sample (for PCM: 16, 24, 32).
- ///
- [JsonPropertyName("bit_depth")]
- public int? BitDepth { get; set; }
-
- ///
- /// Bitrate in kbps (for lossy codecs like Opus).
- ///
- [JsonPropertyName("bitrate")]
- public int? Bitrate { get; set; }
-
- ///
- /// Codec-specific header data (base64 encoded).
- /// For FLAC, this contains the STREAMINFO block.
- ///
- [JsonPropertyName("codec_header")]
- public string? CodecHeader { get; set; }
-
- public override string ToString()
- {
- var bitInfo = Bitrate.HasValue ? $" @ {Bitrate}kbps" : BitDepth.HasValue ? $" {BitDepth}bit" : "";
- return $"{Codec.ToUpperInvariant()} {SampleRate}Hz {Channels}ch{bitInfo}";
- }
-}
-
-///
-/// Common audio codec identifiers used in the Sendspin protocol.
-///
-public static class AudioCodecs
-{
- public const string Opus = "opus";
- public const string Flac = "flac";
- public const string Pcm = "pcm";
-}
diff --git a/src/Sendspin.SDK/Models/GroupState.cs b/src/Sendspin.SDK/Models/GroupState.cs
deleted file mode 100644
index 31f5902..0000000
--- a/src/Sendspin.SDK/Models/GroupState.cs
+++ /dev/null
@@ -1,77 +0,0 @@
-using System.Text.Json.Serialization;
-
-namespace Sendspin.SDK.Models;
-
-///
-/// Aggregate state for display purposes. Populated from multiple message types.
-///
-///
-/// Field sources per Sendspin spec:
-///
-/// - GroupId, Name, PlaybackState - from group/update
-/// - Volume, Muted - from server/state controller object
-/// - Metadata, Shuffle, Repeat - from server/state metadata object
-///
-///
-/// Note: Per Sendspin spec, group/update only contains playback state and identity.
-/// Volume, mute, and metadata are delivered separately via server/state.
-///
-///
-public sealed class GroupState
-{
- ///
- /// Unique group identifier.
- ///
- /// Source: group/update message.
- [JsonPropertyName("group_id")]
- public string GroupId { get; set; } = string.Empty;
-
- ///
- /// Display name for the group.
- ///
- /// Source: group/update message.
- [JsonPropertyName("name")]
- public string? Name { get; set; }
-
- ///
- /// Current playback state (playing, paused, stopped, idle).
- ///
- /// Source: group/update message.
- [JsonPropertyName("playback_state")]
- public PlaybackState PlaybackState { get; set; } = PlaybackState.Idle;
-
- ///
- /// Group volume level (0-100).
- ///
- /// Source: server/state controller object.
- [JsonPropertyName("volume")]
- public int Volume { get; set; } = 100;
-
- ///
- /// Whether the group is muted.
- ///
- /// Source: server/state controller object.
- [JsonPropertyName("muted")]
- public bool Muted { get; set; }
-
- ///
- /// Current track metadata.
- ///
- /// Source: server/state metadata object.
- [JsonPropertyName("metadata")]
- public TrackMetadata? Metadata { get; set; }
-
- ///
- /// Whether shuffle is enabled.
- ///
- /// Source: server/state metadata object.
- [JsonPropertyName("shuffle")]
- public bool Shuffle { get; set; }
-
- ///
- /// Repeat mode ("off", "one", "all").
- ///
- /// Source: server/state metadata object.
- [JsonPropertyName("repeat")]
- public string? Repeat { get; set; }
-}
diff --git a/src/Sendspin.SDK/Models/PlaybackState.cs b/src/Sendspin.SDK/Models/PlaybackState.cs
deleted file mode 100644
index f3c6d73..0000000
--- a/src/Sendspin.SDK/Models/PlaybackState.cs
+++ /dev/null
@@ -1,41 +0,0 @@
-using System.Text.Json.Serialization;
-
-namespace Sendspin.SDK.Models;
-
-///
-/// Represents the current playback state of a group.
-/// JSON serialization uses snake_case naming via JsonStringEnumConverter.
-///
-[JsonConverter(typeof(JsonStringEnumConverter))]
-public enum PlaybackState
-{
- ///
- /// No media loaded or stopped.
- ///
- [JsonPropertyName("idle")]
- Idle,
-
- ///
- /// Stopped state (alias for Idle, used by some servers).
- ///
- [JsonPropertyName("stopped")]
- Stopped,
-
- ///
- /// Currently playing audio.
- ///
- [JsonPropertyName("playing")]
- Playing,
-
- ///
- /// Playback paused.
- ///
- [JsonPropertyName("paused")]
- Paused,
-
- ///
- /// Error state (e.g., buffer underrun, codec error).
- ///
- [JsonPropertyName("error")]
- Error
-}
diff --git a/src/Sendspin.SDK/Models/PlayerState.cs b/src/Sendspin.SDK/Models/PlayerState.cs
deleted file mode 100644
index f9224dc..0000000
--- a/src/Sendspin.SDK/Models/PlayerState.cs
+++ /dev/null
@@ -1,38 +0,0 @@
-namespace Sendspin.SDK.Models;
-
-///
-/// Represents this player's own volume and mute state.
-///
-///
-///
-/// This is distinct from which represents the group average.
-/// The server controls player volume via server/command messages, while
-/// group/update and server/state provide the group aggregate for display.
-///
-///
-/// Per the Sendspin spec, group volume is calculated as the average of all player
-/// volumes in the group. When a controller adjusts group volume, the server sends
-/// individual server/command messages to each player with their new volume
-/// (calculated via the redistribution algorithm that preserves relative differences).
-///
-///
-public sealed class PlayerState
-{
- ///
- /// This player's volume (0-100). Applied to audio output.
- ///
- ///
- /// Set by server/command messages from the server, or by local user input.
- /// This value is sent back to the server via client/state messages.
- ///
- public int Volume { get; set; } = 100;
-
- ///
- /// Whether this player is muted. Applied to audio output.
- ///
- ///
- /// Set by server/command messages from the server, or by local user input.
- /// This value is sent back to the server via client/state messages.
- ///
- public bool Muted { get; set; }
-}
diff --git a/src/Sendspin.SDK/Models/ServerInfo.cs b/src/Sendspin.SDK/Models/ServerInfo.cs
deleted file mode 100644
index 9a2206a..0000000
--- a/src/Sendspin.SDK/Models/ServerInfo.cs
+++ /dev/null
@@ -1,49 +0,0 @@
-namespace Sendspin.SDK.Models;
-
-///
-/// Information about a discovered Sendspin server.
-///
-public sealed class ServerInfo
-{
- ///
- /// Unique server identifier.
- ///
- required public string ServerId { get; init; }
-
- ///
- /// Human-readable server name.
- ///
- required public string Name { get; init; }
-
- ///
- /// Server hostname or IP address.
- ///
- required public string Host { get; init; }
-
- ///
- /// Server port number.
- ///
- required public int Port { get; init; }
-
- ///
- /// Full WebSocket URI for connection.
- ///
- public string WebSocketUri => $"ws://{Host}:{Port}/sendspin";
-
- ///
- /// Protocol version supported by the server.
- ///
- public string? ProtocolVersion { get; init; }
-
- ///
- /// Whether the server was discovered via mDNS.
- ///
- public bool IsDiscovered { get; init; }
-
- ///
- /// Time when this server was last seen.
- ///
- public DateTimeOffset LastSeen { get; set; } = DateTimeOffset.UtcNow;
-
- public override string ToString() => $"{Name} ({Host}:{Port})";
-}
diff --git a/src/Sendspin.SDK/Models/TrackMetadata.cs b/src/Sendspin.SDK/Models/TrackMetadata.cs
deleted file mode 100644
index ec888fd..0000000
--- a/src/Sendspin.SDK/Models/TrackMetadata.cs
+++ /dev/null
@@ -1,110 +0,0 @@
-using System.Text.Json.Serialization;
-using Sendspin.SDK.Protocol.Messages;
-
-namespace Sendspin.SDK.Models;
-
-///
-/// Track metadata per Sendspin spec (server/state metadata object).
-///
-///
-/// This model aligns with the Sendspin protocol specification for metadata.
-/// All fields are populated from server/state message's metadata object.
-///
-/// Duration and Position are computed from the nested Progress object for
-/// backward compatibility with existing consumers.
-///
-///
-public sealed class TrackMetadata
-{
- ///
- /// Server timestamp (microseconds) when this metadata is valid.
- ///
- [JsonPropertyName("timestamp")]
- public long? Timestamp { get; set; }
-
- ///
- /// Track title.
- ///
- [JsonPropertyName("title")]
- public string? Title { get; set; }
-
- ///
- /// Artist name(s).
- ///
- [JsonPropertyName("artist")]
- public string? Artist { get; set; }
-
- ///
- /// Album artist (may differ from track artist on compilations).
- ///
- [JsonPropertyName("album_artist")]
- public string? AlbumArtist { get; set; }
-
- ///
- /// Album name.
- ///
- [JsonPropertyName("album")]
- public string? Album { get; set; }
-
- ///
- /// URL for album artwork.
- ///
- [JsonPropertyName("artwork_url")]
- public string? ArtworkUrl { get; set; }
-
- ///
- /// Release year.
- ///
- [JsonPropertyName("year")]
- public int? Year { get; set; }
-
- ///
- /// Track number on album.
- ///
- [JsonPropertyName("track")]
- public int? Track { get; set; }
-
- ///
- /// Playback progress information (position, duration, speed).
- ///
- [JsonPropertyName("progress")]
- public PlaybackProgress? Progress { get; set; }
-
- ///
- /// Repeat mode ("off", "one", "all").
- ///
- [JsonPropertyName("repeat")]
- public string? Repeat { get; set; }
-
- ///
- /// Whether shuffle is enabled.
- ///
- [JsonPropertyName("shuffle")]
- public bool? Shuffle { get; set; }
-
- ///
- /// Track duration in seconds (computed from Progress.TrackDuration).
- ///
- ///
- /// Backward-compatibility property. Progress.TrackDuration is in milliseconds per spec.
- ///
- [JsonIgnore]
- public double? Duration => Progress?.TrackDuration / 1000.0;
-
- ///
- /// Current playback position in seconds (computed from Progress.TrackProgress).
- ///
- ///
- /// Backward-compatibility property. Progress.TrackProgress is in milliseconds per spec.
- ///
- [JsonIgnore]
- public double? Position => Progress?.TrackProgress / 1000.0;
-
- ///
- public override string ToString()
- {
- if (!string.IsNullOrEmpty(Artist))
- return $"{Artist} - {Title}";
- return Title ?? "Unknown Track";
- }
-}
diff --git a/src/Sendspin.SDK/Protocol/BinaryMessageParser.cs b/src/Sendspin.SDK/Protocol/BinaryMessageParser.cs
deleted file mode 100644
index f847820..0000000
--- a/src/Sendspin.SDK/Protocol/BinaryMessageParser.cs
+++ /dev/null
@@ -1,172 +0,0 @@
-using System.Buffers.Binary;
-using Sendspin.SDK.Protocol.Messages;
-
-namespace Sendspin.SDK.Protocol;
-
-///
-/// Parses binary protocol messages (audio chunks, artwork, visualizer data).
-///
-public static class BinaryMessageParser
-{
- ///
- /// Minimum binary message size (1 byte type + 8 bytes timestamp).
- ///
- public const int MinimumMessageSize = 9;
-
- ///
- /// Parses a binary message header.
- ///
- /// Raw binary message data.
- /// The message type identifier.
- /// Server timestamp in microseconds.
- /// The payload data after the header.
- /// True if parsing succeeded.
- public static bool TryParse(
- ReadOnlySpan data,
- out byte messageType,
- out long timestamp,
- out ReadOnlySpan payload)
- {
- messageType = 0;
- timestamp = 0;
- payload = default;
-
- if (data.Length < MinimumMessageSize)
- {
- return false;
- }
-
- messageType = data[0];
- timestamp = BinaryPrimitives.ReadInt64BigEndian(data.Slice(1, 8));
- payload = data.Slice(MinimumMessageSize);
-
- return true;
- }
-
- ///
- /// Parses a binary audio message.
- ///
- public static AudioChunk? ParseAudioChunk(ReadOnlySpan data)
- {
- if (!TryParse(data, out var type, out var timestamp, out var payload))
- {
- return null;
- }
-
- if (!BinaryMessageTypes.IsPlayerAudio(type))
- {
- return null;
- }
-
- return new AudioChunk
- {
- Slot = (byte)(type - BinaryMessageTypes.PlayerAudio0),
- ServerTimestamp = timestamp,
- EncodedData = payload.ToArray()
- };
- }
-
- ///
- /// Parses a binary artwork message.
- ///
- public static ArtworkChunk? ParseArtworkChunk(ReadOnlySpan data)
- {
- if (!TryParse(data, out var type, out var timestamp, out var payload))
- {
- return null;
- }
-
- if (!BinaryMessageTypes.IsArtwork(type))
- {
- return null;
- }
-
- return new ArtworkChunk
- {
- Channel = (byte)(type - BinaryMessageTypes.Artwork0),
- Timestamp = timestamp,
- ImageData = payload.ToArray()
- };
- }
-
- ///
- /// Gets the message category from a binary message type byte.
- ///
- public static BinaryMessageCategory GetCategory(byte messageType)
- {
- if (BinaryMessageTypes.IsPlayerAudio(messageType))
- return BinaryMessageCategory.PlayerAudio;
- if (BinaryMessageTypes.IsArtwork(messageType))
- return BinaryMessageCategory.Artwork;
- if (BinaryMessageTypes.IsVisualizer(messageType))
- return BinaryMessageCategory.Visualizer;
- if (messageType >= 192)
- return BinaryMessageCategory.ApplicationSpecific;
-
- return BinaryMessageCategory.Unknown;
- }
-}
-
-///
-/// Categories of binary messages.
-///
-public enum BinaryMessageCategory
-{
- Unknown,
- PlayerAudio,
- Artwork,
- Visualizer,
- ApplicationSpecific
-}
-
-///
-/// Represents a parsed audio chunk.
-///
-public sealed class AudioChunk
-{
- ///
- /// Audio slot (0-3, for multi-stream).
- ///
- public byte Slot { get; init; }
-
- ///
- /// Server timestamp when this audio should be played (microseconds).
- ///
- public long ServerTimestamp { get; init; }
-
- ///
- /// Encoded audio data (Opus/FLAC/PCM).
- ///
- required public byte[] EncodedData { get; init; }
-
- ///
- /// Decoded PCM samples (set after decoding).
- ///
- public float[]? DecodedSamples { get; set; }
-
- ///
- /// Playback position within decoded samples.
- ///
- public int PlaybackPosition { get; set; }
-}
-
-///
-/// Represents a parsed artwork chunk.
-///
-public sealed class ArtworkChunk
-{
- ///
- /// Artwork channel (0-3).
- ///
- public byte Channel { get; init; }
-
- ///
- /// Timestamp for this artwork.
- ///
- public long Timestamp { get; init; }
-
- ///
- /// Raw image data (JPEG/PNG).
- ///
- required public byte[] ImageData { get; init; }
-}
diff --git a/src/Sendspin.SDK/Protocol/MessageSerializer.cs b/src/Sendspin.SDK/Protocol/MessageSerializer.cs
deleted file mode 100644
index 8520f74..0000000
--- a/src/Sendspin.SDK/Protocol/MessageSerializer.cs
+++ /dev/null
@@ -1,112 +0,0 @@
-using System.Text.Json;
-using System.Text.Json.Serialization.Metadata;
-using Sendspin.SDK.Protocol.Messages;
-
-namespace Sendspin.SDK.Protocol;
-
-///
-/// Handles serialization and deserialization of Sendspin protocol messages.
-/// Uses source-generated JsonSerializerContext for NativeAOT compatibility.
-///
-public static class MessageSerializer
-{
- private static readonly MessageSerializerContext s_context = MessageSerializerContext.Default;
-
- private static JsonTypeInfo GetTypeInfo() =>
- (JsonTypeInfo)s_context.GetTypeInfo(typeof(T))!;
-
- ///
- /// Serializes a message to JSON string.
- ///
- public static string Serialize(T message) where T : IMessage
- {
- return JsonSerializer.Serialize(message, GetTypeInfo());
- }
-
- ///
- /// Serializes a message to UTF-8 bytes.
- ///
- public static byte[] SerializeToBytes(T message) where T : IMessage
- {
- return JsonSerializer.SerializeToUtf8Bytes(message, GetTypeInfo());
- }
-
- ///
- /// Deserializes a JSON message, returning the appropriate message type.
- ///
- public static IMessage? Deserialize(string json)
- {
- // First, parse to get the message type
- using var doc = JsonDocument.Parse(json);
- if (!doc.RootElement.TryGetProperty("type", out var typeProp))
- {
- return null;
- }
-
- var messageType = typeProp.GetString();
- return messageType switch
- {
- MessageTypes.ServerHello => JsonSerializer.Deserialize(json, s_context.ServerHelloMessage),
- MessageTypes.ServerTime => JsonSerializer.Deserialize(json, s_context.ServerTimeMessage),
- MessageTypes.StreamStart => JsonSerializer.Deserialize(json, s_context.StreamStartMessage),
- MessageTypes.StreamEnd => JsonSerializer.Deserialize(json, s_context.StreamEndMessage),
- MessageTypes.StreamClear => JsonSerializer.Deserialize(json, s_context.StreamClearMessage),
- MessageTypes.GroupUpdate => JsonSerializer.Deserialize(json, s_context.GroupUpdateMessage),
- MessageTypes.ServerCommand => JsonSerializer.Deserialize(json, s_context.ServerCommandMessage),
- _ => null // Unknown message type
- };
- }
-
- ///
- /// Deserializes a specific message type.
- ///
- public static T? Deserialize(string json) where T : class, IMessage
- {
- return JsonSerializer.Deserialize(json, GetTypeInfo());
- }
-
- ///
- /// Gets the message type from a JSON string without full deserialization.
- ///
- public static string? GetMessageType(string json)
- {
- try
- {
- using var doc = JsonDocument.Parse(json);
- if (doc.RootElement.TryGetProperty("type", out var typeProp))
- {
- return typeProp.GetString();
- }
- }
- catch (JsonException)
- {
- // Invalid JSON
- }
- return null;
- }
-
- ///
- /// Gets the message type from a UTF-8 byte span without full deserialization.
- ///
- public static string? GetMessageType(ReadOnlySpan utf8Json)
- {
- try
- {
- var reader = new Utf8JsonReader(utf8Json);
- while (reader.Read())
- {
- if (reader.TokenType == JsonTokenType.PropertyName &&
- reader.ValueTextEquals("type"u8))
- {
- reader.Read();
- return reader.GetString();
- }
- }
- }
- catch (JsonException)
- {
- // Invalid JSON
- }
- return null;
- }
-}
diff --git a/src/Sendspin.SDK/Protocol/MessageSerializerContext.cs b/src/Sendspin.SDK/Protocol/MessageSerializerContext.cs
deleted file mode 100644
index b5afc8f..0000000
--- a/src/Sendspin.SDK/Protocol/MessageSerializerContext.cs
+++ /dev/null
@@ -1,49 +0,0 @@
-using System.Text.Json;
-using System.Text.Json.Serialization;
-using Sendspin.SDK.Protocol.Messages;
-
-namespace Sendspin.SDK.Protocol;
-
-///
-/// Source-generated JSON serializer context for all Sendspin protocol messages.
-/// Enables NativeAOT-compatible serialization without runtime reflection.
-///
-///
-/// When adding a new message type, add a [JsonSerializable(typeof(NewMessageType))]
-/// attribute here to include it in source generation.
-///
-[JsonSourceGenerationOptions(
- PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower,
- DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
- Converters = [typeof(SnakeCaseEnumConverter), typeof(OptionalJsonConverterFactory)])]
-[JsonSerializable(typeof(ClientHelloMessage))]
-[JsonSerializable(typeof(ClientGoodbyeMessage))]
-[JsonSerializable(typeof(ClientTimeMessage))]
-[JsonSerializable(typeof(ClientCommandMessage))]
-[JsonSerializable(typeof(ClientStateMessage))]
-[JsonSerializable(typeof(ClientSyncOffsetMessage))]
-[JsonSerializable(typeof(ClientSyncOffsetAckMessage))]
-[JsonSerializable(typeof(StreamRequestFormatMessage))]
-[JsonSerializable(typeof(ServerHelloMessage))]
-[JsonSerializable(typeof(ServerTimeMessage))]
-[JsonSerializable(typeof(StreamStartMessage))]
-[JsonSerializable(typeof(StreamEndMessage))]
-[JsonSerializable(typeof(StreamClearMessage))]
-[JsonSerializable(typeof(GroupUpdateMessage))]
-[JsonSerializable(typeof(ServerCommandMessage))]
-[JsonSerializable(typeof(ServerStateMessage))]
-internal partial class MessageSerializerContext : JsonSerializerContext
-{
-}
-
-///
-/// Concrete enum converter for source generation (JsonStringEnumConverter cannot be
-/// used directly in [JsonSourceGenerationOptions] Converters array).
-///
-internal sealed class SnakeCaseEnumConverter : JsonStringEnumConverter
-{
- public SnakeCaseEnumConverter()
- : base(JsonNamingPolicy.SnakeCaseLower)
- {
- }
-}
diff --git a/src/Sendspin.SDK/Protocol/Messages/ClientCommandMessage.cs b/src/Sendspin.SDK/Protocol/Messages/ClientCommandMessage.cs
deleted file mode 100644
index b2d8ee0..0000000
--- a/src/Sendspin.SDK/Protocol/Messages/ClientCommandMessage.cs
+++ /dev/null
@@ -1,93 +0,0 @@
-using System.Text.Json.Serialization;
-
-namespace Sendspin.SDK.Protocol.Messages;
-
-///
-/// Command message sent from client to control playback.
-/// Uses the envelope format: { "type": "client/command", "payload": { "controller": { ... } } }
-///
-public sealed class ClientCommandMessage : IMessageWithPayload
-{
- [JsonPropertyName("type")]
- public string Type => MessageTypes.ClientCommand;
-
- [JsonPropertyName("payload")]
- required public ClientCommandPayload Payload { get; init; }
-
- ///
- /// Creates a command message with the specified command.
- ///
- public static ClientCommandMessage Create(string command, int? volume = null, bool? mute = null)
- {
- return new ClientCommandMessage
- {
- Payload = new ClientCommandPayload
- {
- Controller = new ControllerCommand
- {
- Command = command,
- Volume = volume,
- Mute = mute
- }
- }
- };
- }
-}
-
-///
-/// Payload for client/command message.
-///
-public sealed class ClientCommandPayload
-{
- ///
- /// Controller commands for playback control.
- ///
- [JsonPropertyName("controller")]
- required public ControllerCommand Controller { get; init; }
-}
-
-///
-/// Controller command details.
-///
-public sealed class ControllerCommand
-{
- ///
- /// Command to execute (e.g., "play", "pause", "next", "previous").
- ///
- [JsonPropertyName("command")]
- required public string Command { get; init; }
-
- ///
- /// Volume level (0-100), only used when command is "volume".
- ///
- [JsonPropertyName("volume")]
- [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
- public int? Volume { get; init; }
-
- ///
- /// Mute state, only used when command is "mute".
- ///
- [JsonPropertyName("mute")]
- [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
- public bool? Mute { get; init; }
-}
-
-///
-/// Common command identifiers per the Sendspin spec.
-///
-public static class Commands
-{
- public const string Play = "play";
- public const string Pause = "pause";
- public const string Stop = "stop";
- public const string Next = "next";
- public const string Previous = "previous";
- public const string Volume = "volume";
- public const string Mute = "mute";
- public const string Shuffle = "shuffle";
- public const string Unshuffle = "unshuffle";
- public const string RepeatOff = "repeat_off";
- public const string RepeatOne = "repeat_one";
- public const string RepeatAll = "repeat_all";
- public const string Switch = "switch";
-}
diff --git a/src/Sendspin.SDK/Protocol/Messages/ClientGoodbyeMessage.cs b/src/Sendspin.SDK/Protocol/Messages/ClientGoodbyeMessage.cs
deleted file mode 100644
index 0369250..0000000
--- a/src/Sendspin.SDK/Protocol/Messages/ClientGoodbyeMessage.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-using System.Text.Json.Serialization;
-
-namespace Sendspin.SDK.Protocol.Messages;
-
-///
-/// Message sent by client when disconnecting gracefully.
-/// Uses the envelope format: { "type": "client/goodbye", "payload": { ... } }
-///
-public sealed class ClientGoodbyeMessage : IMessageWithPayload
-{
- [JsonPropertyName("type")]
- public string Type => MessageTypes.ClientGoodbye;
-
- [JsonPropertyName("payload")]
- required public ClientGoodbyePayload Payload { get; init; }
-
- ///
- /// Creates a ClientGoodbyeMessage with the specified reason.
- ///
- public static ClientGoodbyeMessage Create(string reason = "user_request")
- {
- return new ClientGoodbyeMessage
- {
- Payload = new ClientGoodbyePayload { Reason = reason }
- };
- }
-}
-
-///
-/// Payload for the client/goodbye message.
-///
-public sealed class ClientGoodbyePayload
-{
- ///
- /// Reason for disconnection.
- ///
- [JsonPropertyName("reason")]
- public string Reason { get; init; } = "user_request";
-}
diff --git a/src/Sendspin.SDK/Protocol/Messages/ClientHelloMessage.cs b/src/Sendspin.SDK/Protocol/Messages/ClientHelloMessage.cs
deleted file mode 100644
index 357cc27..0000000
--- a/src/Sendspin.SDK/Protocol/Messages/ClientHelloMessage.cs
+++ /dev/null
@@ -1,207 +0,0 @@
-using System.Text.Json.Serialization;
-using Sendspin.SDK.Models;
-
-namespace Sendspin.SDK.Protocol.Messages;
-
-///
-/// Initial handshake message sent by the client to announce its capabilities.
-/// Uses the envelope format: { "type": "client/hello", "payload": { ... } }
-///
-public sealed class ClientHelloMessage : IMessageWithPayload
-{
- [JsonPropertyName("type")]
- public string Type => MessageTypes.ClientHello;
-
- [JsonPropertyName("payload")]
- required public ClientHelloPayload Payload { get; init; }
-
- ///
- /// Creates a ClientHelloMessage with the specified payload.
- ///
- public static ClientHelloMessage Create(
- string clientId,
- string name,
- List supportedRoles,
- PlayerSupport? playerSupport = null,
- ArtworkSupport? artworkSupport = null,
- DeviceInfo? deviceInfo = null)
- {
- return new ClientHelloMessage
- {
- Payload = new ClientHelloPayload
- {
- ClientId = clientId,
- Name = name,
- Version = 1,
- SupportedRoles = supportedRoles,
- PlayerV1Support = playerSupport,
- ArtworkV1Support = artworkSupport,
- DeviceInfo = deviceInfo
- }
- };
- }
-}
-
-///
-/// Payload for the client/hello message.
-///
-public sealed class ClientHelloPayload
-{
- ///
- /// Unique client identifier (persistent across sessions).
- ///
- [JsonPropertyName("client_id")]
- required public string ClientId { get; init; }
-
- ///
- /// Human-readable client name.
- ///
- [JsonPropertyName("name")]
- required public string Name { get; init; }
-
- ///
- /// Protocol version (must be 1).
- ///
- [JsonPropertyName("version")]
- public int Version { get; init; } = 1;
-
- ///
- /// List of roles the client supports, in priority order.
- /// Each role includes version (e.g., "player@v1", "controller@v1").
- ///
- [JsonPropertyName("supported_roles")]
- required public List SupportedRoles { get; init; }
-
- ///
- /// Player role support details (per Sendspin spec).
- ///
- [JsonPropertyName("player@v1_support")]
- [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
- public PlayerSupport? PlayerV1Support { get; init; }
-
- ///
- /// Artwork role support details.
- ///
- [JsonPropertyName("artwork@v1_support")]
- [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
- public ArtworkSupport? ArtworkV1Support { get; init; }
-
- ///
- /// Device information.
- ///
- [JsonPropertyName("device_info")]
- [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
- public DeviceInfo? DeviceInfo { get; init; }
-}
-
-///
-/// Player role support details per the Sendspin spec.
-///
-public sealed class PlayerSupport
-{
- ///
- /// Supported audio formats.
- ///
- [JsonPropertyName("supported_formats")]
- public List SupportedFormats { get; init; } = new();
-
- ///
- /// Audio buffer capacity in bytes.
- ///
- [JsonPropertyName("buffer_capacity")]
- public int BufferCapacity { get; init; } = 32_000_000; // 32MB like reference impl
-
- ///
- /// Supported player commands.
- ///
- [JsonPropertyName("supported_commands")]
- public List SupportedCommands { get; init; } = new() { "volume", "mute" };
-}
-
-///
-/// Audio format specification for player support.
-///
-public sealed class AudioFormatSpec
-{
- [JsonPropertyName("codec")]
- required public string Codec { get; init; }
-
- [JsonPropertyName("channels")]
- public int Channels { get; init; } = 2;
-
- [JsonPropertyName("sample_rate")]
- public int SampleRate { get; init; } = 48000;
-
- [JsonPropertyName("bit_depth")]
- public int BitDepth { get; init; } = 16;
-}
-
-///
-/// Artwork role support details per the Sendspin spec.
-///
-public sealed class ArtworkSupport
-{
- ///
- /// Artwork channel specifications. Each element corresponds to a channel (0-3).
- ///
- [JsonPropertyName("channels")]
- public List Channels { get; init; } = new();
-}
-
-///
-/// Specification for a single artwork channel.
-///
-public sealed class ArtworkChannelSpec
-{
- ///
- /// The source type for this artwork channel.
- ///
- [JsonPropertyName("source")]
- public string Source { get; init; } = "album";
-
- ///
- /// Preferred image format for this channel.
- ///
- [JsonPropertyName("format")]
- public string Format { get; init; } = "jpeg";
-
- ///
- /// Maximum image width in pixels.
- ///
- [JsonPropertyName("media_width")]
- public int MediaWidth { get; init; } = 512;
-
- ///
- /// Maximum image height in pixels.
- ///
- [JsonPropertyName("media_height")]
- public int MediaHeight { get; init; } = 512;
-}
-
-///
-/// Device information reported to the server.
-/// All fields are optional and will be omitted from JSON if null.
-///
-public sealed class DeviceInfo
-{
- ///
- /// Product name (e.g., "Sendspin Windows Client", "My Custom Player").
- ///
- [JsonPropertyName("product_name")]
- [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
- public string? ProductName { get; init; }
-
- ///
- /// Manufacturer name (e.g., "Anthropic", "My Company").
- ///
- [JsonPropertyName("manufacturer")]
- [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
- public string? Manufacturer { get; init; }
-
- ///
- /// Software version string.
- ///
- [JsonPropertyName("software_version")]
- [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
- public string? SoftwareVersion { get; init; }
-}
diff --git a/src/Sendspin.SDK/Protocol/Messages/ClientStateMessage.cs b/src/Sendspin.SDK/Protocol/Messages/ClientStateMessage.cs
deleted file mode 100644
index be4bb4b..0000000
--- a/src/Sendspin.SDK/Protocol/Messages/ClientStateMessage.cs
+++ /dev/null
@@ -1,131 +0,0 @@
-using System.Text.Json.Serialization;
-
-namespace Sendspin.SDK.Protocol.Messages;
-
-///
-/// State update message sent from client to server.
-/// Used to report client state (synchronized, error, external_source)
-/// and player state (volume, mute).
-///
-public sealed class ClientStateMessage : IMessageWithPayload
-{
- [JsonPropertyName("type")]
- public string Type => MessageTypes.ClientState;
-
- [JsonPropertyName("payload")]
- required public ClientStatePayload Payload { get; init; }
-
- ///
- /// Creates a synchronized state message with player volume/mute.
- /// This should be sent immediately after receiving server/hello.
- ///
- /// 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
- {
- Payload = new ClientStatePayload
- {
- State = "synchronized",
- Player = new PlayerStatePayload
- {
- Volume = volume,
- Muted = muted,
- StaticDelayMs = staticDelayMs
- }
- }
- };
- }
-
- ///
- /// Creates an error state message.
- ///
- /// Optional error message (SDK extension).
- public static ClientStateMessage CreateError(string? errorMessage = null)
- {
- return new ClientStateMessage
- {
- Payload = new ClientStatePayload
- {
- State = "error",
- Player = errorMessage != null ? new PlayerStatePayload { Error = errorMessage } : null
- }
- };
- }
-}
-
-///
-/// Payload for client/state message.
-///
-public sealed class ClientStatePayload
-{
- ///
- /// Client state: "synchronized", "error", or "external_source".
- ///
- [JsonPropertyName("state")]
- [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
- public string? State { get; init; }
-
- ///
- /// Player-specific state (volume, mute, buffer level).
- /// Only included if client has player role.
- ///
- [JsonPropertyName("player")]
- [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
- public PlayerStatePayload? Player { get; init; }
-}
-
-///
-/// Player-specific state within client/state message.
-///
-///
-/// Per Sendspin spec, the player object contains volume and muted.
-/// The buffer_level and error fields are SDK extensions for diagnostics.
-///
-public sealed class PlayerStatePayload
-{
- ///
- /// Player volume (0-100).
- ///
- [JsonPropertyName("volume")]
- [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
- public int? Volume { get; init; }
-
- ///
- /// Whether the player is muted.
- ///
- [JsonPropertyName("muted")]
- [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
- public bool? Muted { get; init; }
-
- ///
- /// Buffer level in milliseconds.
- ///
- ///
- /// SDK extension (not part of Sendspin spec). Used for diagnostic reporting.
- ///
- [JsonPropertyName("buffer_level")]
- [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
- public int? BufferLevel { get; init; }
-
- ///
- /// Error message if in error state.
- ///
- ///
- /// SDK extension (not part of Sendspin spec). Used for error reporting.
- ///
- [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/Protocol/Messages/ClientSyncOffsetMessage.cs b/src/Sendspin.SDK/Protocol/Messages/ClientSyncOffsetMessage.cs
deleted file mode 100644
index 756b511..0000000
--- a/src/Sendspin.SDK/Protocol/Messages/ClientSyncOffsetMessage.cs
+++ /dev/null
@@ -1,129 +0,0 @@
-using System.Text.Json.Serialization;
-
-namespace Sendspin.SDK.Protocol.Messages;
-
-///
-/// Sync offset message received from GroupSync calibration tool.
-/// Used to adjust static delay for speaker synchronization.
-/// Format: { "type": "client/sync_offset", "payload": { "player_id": "...", "offset_ms": 12.5, "source": "groupsync" } }
-///
-public sealed class ClientSyncOffsetMessage : IMessageWithPayload
-{
- [JsonPropertyName("type")]
- public string Type => MessageTypes.ClientSyncOffset;
-
- [JsonPropertyName("payload")]
- required public ClientSyncOffsetPayload Payload { get; init; }
-
- ///
- /// Creates a sync offset message with the specified offset.
- ///
- public static ClientSyncOffsetMessage Create(string playerId, double offsetMs, string source = "groupsync")
- {
- return new ClientSyncOffsetMessage
- {
- Payload = new ClientSyncOffsetPayload
- {
- PlayerId = playerId,
- OffsetMs = offsetMs,
- Source = source,
- Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
- }
- };
- }
-}
-
-///
-/// Payload for client/sync_offset message.
-///
-public sealed class ClientSyncOffsetPayload
-{
- ///
- /// Player identifier this offset applies to.
- ///
- [JsonPropertyName("player_id")]
- required public string PlayerId { get; init; }
-
- ///
- /// Offset in milliseconds to apply.
- /// Positive = delay playback (plays later)
- /// Negative = advance playback (plays earlier)
- ///
- [JsonPropertyName("offset_ms")]
- public double OffsetMs { get; init; }
-
- ///
- /// Source of the calibration (e.g., "groupsync", "manual").
- ///
- [JsonPropertyName("source")]
- [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
- public string? Source { get; init; }
-
- ///
- /// Unix timestamp (ms) when the calibration was performed.
- ///
- [JsonPropertyName("timestamp")]
- [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
- public long Timestamp { get; init; }
-}
-
-///
-/// Acknowledgement message sent back after applying sync offset.
-/// Format: { "type": "client/sync_offset_ack", "payload": { ... } }
-///
-public sealed class ClientSyncOffsetAckMessage : IMessageWithPayload
-{
- [JsonPropertyName("type")]
- public string Type => MessageTypes.ClientSyncOffsetAck;
-
- [JsonPropertyName("payload")]
- required public ClientSyncOffsetAckPayload Payload { get; init; }
-
- ///
- /// Creates an acknowledgement for a sync offset message.
- ///
- public static ClientSyncOffsetAckMessage Create(string playerId, double appliedOffsetMs, bool success, string? error = null)
- {
- return new ClientSyncOffsetAckMessage
- {
- Payload = new ClientSyncOffsetAckPayload
- {
- PlayerId = playerId,
- AppliedOffsetMs = appliedOffsetMs,
- Success = success,
- Error = error
- }
- };
- }
-}
-
-///
-/// Payload for client/sync_offset_ack message.
-///
-public sealed class ClientSyncOffsetAckPayload
-{
- ///
- /// Player identifier the offset was applied to.
- ///
- [JsonPropertyName("player_id")]
- required public string PlayerId { get; init; }
-
- ///
- /// The offset that was actually applied (may be clamped).
- ///
- [JsonPropertyName("applied_offset_ms")]
- public double AppliedOffsetMs { get; init; }
-
- ///
- /// Whether the offset was successfully applied.
- ///
- [JsonPropertyName("success")]
- public bool Success { get; init; }
-
- ///
- /// Error message if the offset could not be applied.
- ///
- [JsonPropertyName("error")]
- [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
- public string? Error { get; init; }
-}
diff --git a/src/Sendspin.SDK/Protocol/Messages/ClientTimeMessage.cs b/src/Sendspin.SDK/Protocol/Messages/ClientTimeMessage.cs
deleted file mode 100644
index 0c5219d..0000000
--- a/src/Sendspin.SDK/Protocol/Messages/ClientTimeMessage.cs
+++ /dev/null
@@ -1,68 +0,0 @@
-using System.Text.Json.Serialization;
-using Sendspin.SDK.Synchronization;
-
-namespace Sendspin.SDK.Protocol.Messages;
-
-///
-/// Clock synchronization request sent by client.
-/// Uses the envelope format: { "type": "client/time", "payload": { ... } }
-/// Contains the client's current timestamp for round-trip calculation.
-///
-public sealed class ClientTimeMessage : IMessageWithPayload
-{
- [JsonPropertyName("type")]
- public string Type => MessageTypes.ClientTime;
-
- [JsonPropertyName("payload")]
- public ClientTimePayload Payload { get; set; } = new();
-
- // Convenience accessor (excluded from serialization)
- [JsonIgnore]
- public long ClientTransmitted => Payload.ClientTransmitted;
-
- ///
- /// Creates a new time message with the current timestamp.
- ///
- public static ClientTimeMessage CreateNow()
- {
- return new ClientTimeMessage
- {
- Payload = new ClientTimePayload
- {
- ClientTransmitted = GetCurrentTimestampMicroseconds()
- }
- };
- }
-
- ///
- /// Gets the current timestamp in microseconds using the shared high-precision timer.
- ///
- ///
- ///
- /// CRITICAL: This MUST use the same timer as audio playback timing (HighPrecisionTimer).
- /// Previously this used raw Stopwatch ticks while audio used Unix-based time from
- /// HighPrecisionTimer, causing a time base mismatch of billions of seconds!
- ///
- ///
- /// The clock offset calculation works correctly as long as T1/T4 (client times)
- /// are in the same time base as the audio playback timer.
- ///
- ///
- public static long GetCurrentTimestampMicroseconds()
- {
- return HighPrecisionTimer.Shared.GetCurrentTimeMicroseconds();
- }
-}
-
-///
-/// Payload for the client/time message.
-///
-public sealed class ClientTimePayload
-{
- ///
- /// Client's current monotonic clock timestamp in microseconds.
- /// This is T1 in the NTP-style 4-timestamp exchange.
- ///
- [JsonPropertyName("client_transmitted")]
- public long ClientTransmitted { get; set; }
-}
diff --git a/src/Sendspin.SDK/Protocol/Messages/GroupUpdateMessage.cs b/src/Sendspin.SDK/Protocol/Messages/GroupUpdateMessage.cs
deleted file mode 100644
index fc44561..0000000
--- a/src/Sendspin.SDK/Protocol/Messages/GroupUpdateMessage.cs
+++ /dev/null
@@ -1,61 +0,0 @@
-using System.Text.Json.Serialization;
-using Sendspin.SDK.Models;
-
-namespace Sendspin.SDK.Protocol.Messages;
-
-///
-/// Message from server with group state updates.
-/// Sent when playback state or group identity changes.
-/// Uses the envelope format: { "type": "group/update", "payload": { ... } }
-///
-///
-///
-/// Per Sendspin spec, group/update only contains playback state and group identity.
-/// Volume, mute, and metadata are delivered separately via server/state.
-///
-///
-public sealed class GroupUpdateMessage : IMessageWithPayload
-{
- [JsonPropertyName("type")]
- public string Type => MessageTypes.GroupUpdate;
-
- [JsonPropertyName("payload")]
- public GroupUpdatePayload Payload { get; set; } = new();
-
- // Convenience accessors (excluded from serialization)
- [JsonIgnore]
- public string GroupId => Payload.GroupId;
- [JsonIgnore]
- public string? GroupName => Payload.GroupName;
- [JsonIgnore]
- public PlaybackState? PlaybackState => Payload.PlaybackState;
-}
-
-///
-/// Payload for the group/update message per Sendspin spec.
-///
-///
-/// Contains only playback state and group identity.
-/// Volume/mute comes via server/state controller object.
-/// Metadata comes via server/state metadata object.
-///
-public sealed class GroupUpdatePayload
-{
- ///
- /// Group identifier.
- ///
- [JsonPropertyName("group_id")]
- public string GroupId { get; set; } = string.Empty;
-
- ///
- /// Friendly display name of the group.
- ///
- [JsonPropertyName("group_name")]
- public string? GroupName { get; set; }
-
- ///
- /// Current playback state (playing, paused, stopped).
- ///
- [JsonPropertyName("playback_state")]
- public PlaybackState? PlaybackState { get; set; }
-}
diff --git a/src/Sendspin.SDK/Protocol/Messages/IMessage.cs b/src/Sendspin.SDK/Protocol/Messages/IMessage.cs
deleted file mode 100644
index 73570cb..0000000
--- a/src/Sendspin.SDK/Protocol/Messages/IMessage.cs
+++ /dev/null
@@ -1,58 +0,0 @@
-using System.Text.Json.Serialization;
-
-namespace Sendspin.SDK.Protocol.Messages;
-
-///
-/// Base interface for all Sendspin protocol messages.
-/// Messages use an envelope format: { "type": "...", "payload": { ... } }
-///
-public interface IMessage
-{
- ///
- /// The message type identifier (e.g., "client/hello", "server/time").
- ///
- [JsonPropertyName("type")]
- string Type { get; }
-}
-
-///
-/// Interface for messages with a payload wrapper.
-///
-public interface IMessageWithPayload : IMessage where TPayload : class
-{
- ///
- /// The message payload containing all message-specific data.
- ///
- [JsonPropertyName("payload")]
- TPayload Payload { get; }
-}
-
-///
-/// Generic message envelope that wraps any payload with a type.
-///
-public sealed class MessageEnvelope : IMessageWithPayload where TPayload : class
-{
- [JsonPropertyName("type")]
- required public string Type { get; init; }
-
- [JsonPropertyName("payload")]
- required public TPayload Payload { get; init; }
-}
-
-///
-/// Base class for client-originated messages.
-///
-public abstract class ClientMessage : IMessage
-{
- [JsonPropertyName("type")]
- public abstract string Type { get; }
-}
-
-///
-/// Base class for server-originated messages.
-///
-public abstract class ServerMessage : IMessage
-{
- [JsonPropertyName("type")]
- public abstract string Type { get; }
-}
diff --git a/src/Sendspin.SDK/Protocol/Messages/MessageTypes.cs b/src/Sendspin.SDK/Protocol/Messages/MessageTypes.cs
deleted file mode 100644
index aa50759..0000000
--- a/src/Sendspin.SDK/Protocol/Messages/MessageTypes.cs
+++ /dev/null
@@ -1,62 +0,0 @@
-namespace Sendspin.SDK.Protocol.Messages;
-
-///
-/// Sendspin protocol message type identifiers.
-/// Format: "direction/action" where direction is "client" or "server"
-///
-public static class MessageTypes
-{
- // Handshake
- public const string ClientHello = "client/hello";
- public const string ServerHello = "server/hello";
- public const string ClientGoodbye = "client/goodbye";
-
- // Clock synchronization
- public const string ClientTime = "client/time";
- public const string ServerTime = "server/time";
-
- // Stream lifecycle
- public const string StreamStart = "stream/start";
- public const string StreamEnd = "stream/end";
- public const string StreamClear = "stream/clear";
- public const string StreamRequestFormat = "stream/request-format";
-
- // Group state
- public const string GroupUpdate = "group/update";
-
- // Player commands and state
- public const string ClientCommand = "client/command";
- public const string ServerCommand = "server/command";
- public const string ClientState = "client/state";
- public const string ServerState = "server/state";
-
- // Sync offset (GroupSync calibration)
- public const string ClientSyncOffset = "client/sync_offset";
- public const string ClientSyncOffsetAck = "client/sync_offset_ack";
-}
-
-///
-/// Binary message type identifiers (first byte of binary messages).
-///
-public static class BinaryMessageTypes
-{
- // Player audio (role 1, slots 0-3)
- public const byte PlayerAudio0 = 4;
- public const byte PlayerAudio1 = 5;
- public const byte PlayerAudio2 = 6;
- public const byte PlayerAudio3 = 7;
-
- // Artwork (role 2, slots 0-3)
- public const byte Artwork0 = 8;
- public const byte Artwork1 = 9;
- public const byte Artwork2 = 10;
- public const byte Artwork3 = 11;
-
- // Visualizer (role 4, slots 0-7)
- public const byte Visualizer0 = 16;
- // ... through Visualizer7 = 23
-
- public static bool IsPlayerAudio(byte type) => type >= 4 && type <= 7;
- public static bool IsArtwork(byte type) => type >= 8 && type <= 11;
- public static bool IsVisualizer(byte type) => type >= 16 && type <= 23;
-}
diff --git a/src/Sendspin.SDK/Protocol/Messages/ServerCommandMessage.cs b/src/Sendspin.SDK/Protocol/Messages/ServerCommandMessage.cs
deleted file mode 100644
index c1dd87c..0000000
--- a/src/Sendspin.SDK/Protocol/Messages/ServerCommandMessage.cs
+++ /dev/null
@@ -1,53 +0,0 @@
-using System.Text.Json.Serialization;
-
-namespace Sendspin.SDK.Protocol.Messages;
-
-///
-/// Command message from server to control player state.
-/// The server sends this to tell players what volume/mute to apply locally.
-///
-public sealed class ServerCommandMessage : IMessageWithPayload
-{
- [JsonPropertyName("type")]
- public string Type => MessageTypes.ServerCommand;
-
- [JsonPropertyName("payload")]
- required public ServerCommandPayload Payload { get; init; }
-}
-
-///
-/// Payload for server/command message.
-///
-public sealed class ServerCommandPayload
-{
- ///
- /// Player command details (volume, mute).
- ///
- [JsonPropertyName("player")]
- public PlayerCommand? Player { get; init; }
-}
-
-///
-/// Player command details from server.
-/// Null properties indicate the server is not requesting a change to that setting.
-///
-public sealed class PlayerCommand
-{
- ///
- /// The command type (e.g., "volume", "mute").
- ///
- [JsonPropertyName("command")]
- public string? Command { get; init; }
-
- ///
- /// Volume level (0-100). Null if volume is not being changed.
- ///
- [JsonPropertyName("volume")]
- public int? Volume { get; init; }
-
- ///
- /// Mute state. Null if mute is not being changed.
- ///
- [JsonPropertyName("mute")]
- public bool? Mute { get; init; }
-}
diff --git a/src/Sendspin.SDK/Protocol/Messages/ServerHelloMessage.cs b/src/Sendspin.SDK/Protocol/Messages/ServerHelloMessage.cs
deleted file mode 100644
index 02d7ce6..0000000
--- a/src/Sendspin.SDK/Protocol/Messages/ServerHelloMessage.cs
+++ /dev/null
@@ -1,65 +0,0 @@
-using System.Text.Json.Serialization;
-
-namespace Sendspin.SDK.Protocol.Messages;
-
-///
-/// Server response to client hello, confirming role activations.
-/// Uses the envelope format: { "type": "server/hello", "payload": { ... } }
-///
-public sealed class ServerHelloMessage : IMessageWithPayload
-{
- [JsonPropertyName("type")]
- public string Type => MessageTypes.ServerHello;
-
- [JsonPropertyName("payload")]
- public ServerHelloPayload Payload { get; set; } = new();
-
- // Convenience accessors (excluded from serialization)
- [JsonIgnore]
- public string ServerId => Payload.ServerId;
- [JsonIgnore]
- public string? Name => Payload.Name;
- [JsonIgnore]
- public int Version => Payload.Version;
- [JsonIgnore]
- public List ActiveRoles => Payload.ActiveRoles;
- [JsonIgnore]
- public string? ConnectionReason => Payload.ConnectionReason;
-}
-
-///
-/// Payload for the server/hello message per Sendspin spec.
-///
-public sealed class ServerHelloPayload
-{
- ///
- /// Unique server identifier.
- ///
- [JsonPropertyName("server_id")]
- public string ServerId { get; set; } = string.Empty;
-
- ///
- /// Server name.
- ///
- [JsonPropertyName("name")]
- public string? Name { get; set; }
-
- ///
- /// Protocol version (must be 1).
- ///
- [JsonPropertyName("version")]
- public int Version { get; set; } = 1;
-
- ///
- /// Roles activated by the server for this client.
- /// List of versioned role strings (e.g., ["player@v1", "controller@v1"]).
- ///
- [JsonPropertyName("active_roles")]
- public List ActiveRoles { get; set; } = new();
-
- ///
- /// Reason for this connection (e.g., "discovery", "playback").
- ///
- [JsonPropertyName("connection_reason")]
- public string? ConnectionReason { get; set; }
-}
diff --git a/src/Sendspin.SDK/Protocol/Messages/ServerStateMessage.cs b/src/Sendspin.SDK/Protocol/Messages/ServerStateMessage.cs
deleted file mode 100644
index ec75cc7..0000000
--- a/src/Sendspin.SDK/Protocol/Messages/ServerStateMessage.cs
+++ /dev/null
@@ -1,128 +0,0 @@
-using System.Text.Json.Serialization;
-using Sendspin.SDK.Protocol;
-
-namespace Sendspin.SDK.Protocol.Messages;
-
-///
-/// State update message from server containing metadata and controller state.
-/// This is the primary way Music Assistant sends track metadata to clients.
-///
-public sealed class ServerStateMessage : IMessageWithPayload
-{
- [JsonPropertyName("type")]
- public string Type => MessageTypes.ServerState;
-
- [JsonPropertyName("payload")]
- required public ServerStatePayload Payload { get; init; }
-}
-
-///
-/// Payload for server/state message.
-///
-public sealed class ServerStatePayload
-{
- ///
- /// Current track metadata and playback progress.
- ///
- [JsonPropertyName("metadata")]
- [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
- public ServerMetadata? Metadata { get; init; }
-
- ///
- /// Controller state (volume, mute, supported commands).
- ///
- [JsonPropertyName("controller")]
- [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
- public ControllerState? Controller { get; init; }
-}
-
-///
-/// Track metadata from server/state message.
-///
-public sealed class ServerMetadata
-{
- [JsonPropertyName("timestamp")]
- public long? Timestamp { get; init; }
-
- [JsonPropertyName("title")]
- public string? Title { get; init; }
-
- [JsonPropertyName("artist")]
- public string? Artist { get; init; }
-
- [JsonPropertyName("album_artist")]
- public string? AlbumArtist { get; init; }
-
- [JsonPropertyName("album")]
- public string? Album { get; init; }
-
- [JsonPropertyName("artwork_url")]
- public string? ArtworkUrl { get; init; }
-
- [JsonPropertyName("year")]
- public int? Year { get; init; }
-
- [JsonPropertyName("track")]
- public int? Track { get; init; }
-
- ///
- /// Playback progress information.
- /// Uses to distinguish:
- ///
- /// - Absent: No progress update (keep existing)
- /// - Present but null: Track ended (clear progress)
- /// - Present with value: Update progress
- ///
- ///
- [JsonPropertyName("progress")]
- public Optional Progress { get; init; } = Optional.Absent();
-
- [JsonPropertyName("repeat")]
- public string? Repeat { get; init; }
-
- [JsonPropertyName("shuffle")]
- public bool? Shuffle { get; init; }
-}
-
-///
-/// Playback progress information.
-///
-public sealed class PlaybackProgress
-{
- ///
- /// Current position in milliseconds.
- /// Using double to handle servers that send numeric values as floats.
- ///
- [JsonPropertyName("track_progress")]
- public double? TrackProgress { get; init; }
-
- ///
- /// Total duration in milliseconds.
- /// Using double to handle servers that send numeric values as floats.
- /// Nullable for streams with unknown duration.
- ///
- [JsonPropertyName("track_duration")]
- public double? TrackDuration { get; init; }
-
- ///
- /// Playback speed (1000 = normal speed).
- /// Using double to handle servers that send numeric values as floats.
- ///
- [JsonPropertyName("playback_speed")]
- public double? PlaybackSpeed { get; init; }
-}
-
-///
-/// Controller state from server/state message.
-///
-public sealed class ControllerState
-{
- [JsonPropertyName("supported_commands")]
- public List? SupportedCommands { get; init; }
-
- [JsonPropertyName("volume")]
- public int? Volume { get; init; }
-
- [JsonPropertyName("muted")]
- public bool? Muted { get; init; }
-}
diff --git a/src/Sendspin.SDK/Protocol/Messages/ServerTimeMessage.cs b/src/Sendspin.SDK/Protocol/Messages/ServerTimeMessage.cs
deleted file mode 100644
index bb14631..0000000
--- a/src/Sendspin.SDK/Protocol/Messages/ServerTimeMessage.cs
+++ /dev/null
@@ -1,63 +0,0 @@
-using System.Text.Json.Serialization;
-
-namespace Sendspin.SDK.Protocol.Messages;
-
-///
-/// Clock synchronization response from server.
-/// Uses the envelope format: { "type": "server/time", "payload": { ... } }
-/// Contains timestamps for calculating clock offset and round-trip time.
-///
-public sealed class ServerTimeMessage : IMessageWithPayload
-{
- [JsonPropertyName("type")]
- public string Type => MessageTypes.ServerTime;
-
- [JsonPropertyName("payload")]
- public ServerTimePayload Payload { get; set; } = new();
-
- // Convenience accessors (excluded from serialization)
- [JsonIgnore]
- public long ClientTransmitted => Payload.ClientTransmitted;
- [JsonIgnore]
- public long ServerReceived => Payload.ServerReceived;
- [JsonIgnore]
- public long ServerTransmitted => Payload.ServerTransmitted;
-}
-
-///
-/// Payload for the server/time message.
-///
-public sealed class ServerTimePayload
-{
- ///
- /// Client's transmitted timestamp (T1), echoed back.
- ///
- [JsonPropertyName("client_transmitted")]
- public long ClientTransmitted { get; set; }
-
- ///
- /// Server's receive timestamp (T2) - when the server received client/time.
- ///
- [JsonPropertyName("server_received")]
- public long ServerReceived { get; set; }
-
- ///
- /// Server's transmit timestamp (T3) - when the server sent this response.
- ///
- [JsonPropertyName("server_transmitted")]
- public long ServerTransmitted { get; set; }
-}
-
-/*
- * Clock Offset Calculation (NTP-style):
- *
- * T1 = client_transmitted (client sends)
- * T2 = server_received (server receives)
- * T3 = server_transmitted (server sends)
- * T4 = client receives (measured locally)
- *
- * Offset = ((T2 - T1) + (T3 - T4)) / 2
- * RTT = (T4 - T1) - (T3 - T2)
- *
- * The offset tells us: server_time = client_time + offset
- */
diff --git a/src/Sendspin.SDK/Protocol/Messages/StreamClearMessage.cs b/src/Sendspin.SDK/Protocol/Messages/StreamClearMessage.cs
deleted file mode 100644
index 4ce6c51..0000000
--- a/src/Sendspin.SDK/Protocol/Messages/StreamClearMessage.cs
+++ /dev/null
@@ -1,47 +0,0 @@
-//
-// Licensed under the MIT License. See LICENSE file in the project root.
-//
-
-using System.Text.Json.Serialization;
-
-namespace Sendspin.SDK.Protocol.Messages;
-
-///
-/// Message from server indicating audio buffers should be cleared.
-/// Uses envelope format: { "type": "stream/clear", "payload": { ... } }.
-///
-public sealed class StreamClearMessage : IMessageWithPayload
-{
- ///
- [JsonPropertyName("type")]
- public string Type => MessageTypes.StreamClear;
-
- ///
- [JsonPropertyName("payload")]
- public StreamClearPayload Payload { get; set; } = new();
-
- // Convenience accessors
- [JsonIgnore]
- public string? StreamId => Payload.StreamId;
-
- [JsonIgnore]
- public long? TargetTimestamp => Payload.TargetTimestamp;
-}
-
-///
-/// Payload for stream/clear message.
-///
-public sealed class StreamClearPayload
-{
- ///
- /// Gets or sets the stream identifier.
- ///
- [JsonPropertyName("stream_id")]
- public string? StreamId { get; set; }
-
- ///
- /// Gets or sets the new target timestamp after clear (if seeking).
- ///
- [JsonPropertyName("target_timestamp")]
- public long? TargetTimestamp { get; set; }
-}
diff --git a/src/Sendspin.SDK/Protocol/Messages/StreamEndMessage.cs b/src/Sendspin.SDK/Protocol/Messages/StreamEndMessage.cs
deleted file mode 100644
index 19c7c6a..0000000
--- a/src/Sendspin.SDK/Protocol/Messages/StreamEndMessage.cs
+++ /dev/null
@@ -1,47 +0,0 @@
-//
-// Licensed under the MIT License. See LICENSE file in the project root.
-//
-
-using System.Text.Json.Serialization;
-
-namespace Sendspin.SDK.Protocol.Messages;
-
-///
-/// Message from server indicating audio stream has ended.
-/// Uses envelope format: { "type": "stream/end", "payload": { ... } }.
-///
-public sealed class StreamEndMessage : IMessageWithPayload
-{
- ///
- [JsonPropertyName("type")]
- public string Type => MessageTypes.StreamEnd;
-
- ///
- [JsonPropertyName("payload")]
- public StreamEndPayload Payload { get; set; } = new();
-
- // Convenience accessors
- [JsonIgnore]
- public string? Reason => Payload.Reason;
-
- [JsonIgnore]
- public string? StreamId => Payload.StreamId;
-}
-
-///
-/// Payload for stream/end message.
-///
-public sealed class StreamEndPayload
-{
- ///
- /// Gets or sets the reason for stream ending.
- ///
- [JsonPropertyName("reason")]
- public string? Reason { get; set; }
-
- ///
- /// Gets or sets the stream identifier.
- ///
- [JsonPropertyName("stream_id")]
- public string? StreamId { get; set; }
-}
diff --git a/src/Sendspin.SDK/Protocol/Messages/StreamRequestFormatMessage.cs b/src/Sendspin.SDK/Protocol/Messages/StreamRequestFormatMessage.cs
deleted file mode 100644
index 0dfab17..0000000
--- a/src/Sendspin.SDK/Protocol/Messages/StreamRequestFormatMessage.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-using System.Text.Json.Serialization;
-using Sendspin.SDK.Models;
-
-namespace Sendspin.SDK.Protocol.Messages;
-
-///
-/// Message from client requesting a format change for the audio stream.
-/// Server will respond with a new stream/start if accepted.
-///
-public sealed class StreamRequestFormatMessage : ClientMessage
-{
- [JsonPropertyName("type")]
- public override string Type => MessageTypes.StreamRequestFormat;
-
- ///
- /// Requested audio format.
- ///
- [JsonPropertyName("format")]
- required public AudioFormat Format { get; init; }
-
- ///
- /// Stream identifier.
- ///
- [JsonPropertyName("stream_id")]
- public string? StreamId { get; init; }
-}
diff --git a/src/Sendspin.SDK/Protocol/Messages/StreamStartMessage.cs b/src/Sendspin.SDK/Protocol/Messages/StreamStartMessage.cs
deleted file mode 100644
index 62a6c8b..0000000
--- a/src/Sendspin.SDK/Protocol/Messages/StreamStartMessage.cs
+++ /dev/null
@@ -1,41 +0,0 @@
-//
-// Licensed under the MIT License. See LICENSE file in the project root.
-//
-
-using System.Text.Json.Serialization;
-using Sendspin.SDK.Models;
-
-namespace Sendspin.SDK.Protocol.Messages;
-
-///
-/// Message from server indicating audio stream is starting.
-/// Uses envelope format: { "type": "stream/start", "payload": { ... } }.
-///
-public sealed class StreamStartMessage : IMessageWithPayload
-{
- ///
- [JsonPropertyName("type")]
- public string Type => MessageTypes.StreamStart;
-
- ///
- [JsonPropertyName("payload")]
- public StreamStartPayload Payload { get; set; } = new();
-
- // Convenience accessor
- [JsonIgnore]
- public AudioFormat? Format => Payload.Format;
-}
-
-///
-/// Payload for stream/start message per Sendspin spec.
-///
-public sealed class StreamStartPayload
-{
- ///
- /// Gets or sets the audio format for the incoming stream.
- /// The "player" object contains codec, channels, sample_rate, bit_depth, and codec_header.
- /// Null when the stream/start only carries artwork info (no player key).
- ///
- [JsonPropertyName("player")]
- public AudioFormat? Format { get; set; }
-}
diff --git a/src/Sendspin.SDK/Protocol/Optional.cs b/src/Sendspin.SDK/Protocol/Optional.cs
deleted file mode 100644
index 0b20022..0000000
--- a/src/Sendspin.SDK/Protocol/Optional.cs
+++ /dev/null
@@ -1,64 +0,0 @@
-namespace Sendspin.SDK.Protocol;
-
-///
-/// Represents a JSON field that distinguishes between "absent" (not in JSON)
-/// and "present" (in JSON, possibly null).
-///
-///
-/// Used for JSON fields where explicit null has semantic meaning different from absence.
-/// For example, progress: null means "track ended" while progress absent means "no update".
-/// This matches the CLI's UndefinedField pattern in Python.
-///
-/// The type of the optional value.
-public readonly struct Optional
-{
- private readonly T? _value;
- private readonly bool _isPresent;
-
- private Optional(T? value, bool isPresent)
- {
- _value = value;
- _isPresent = isPresent;
- }
-
- ///
- /// Gets whether the field was present in the JSON (value may be null).
- ///
- public bool IsPresent => _isPresent;
-
- ///
- /// Gets whether the field was absent from the JSON.
- ///
- public bool IsAbsent => !_isPresent;
-
- ///
- /// Gets the value. Throws if absent.
- ///
- /// Thrown if the field was absent.
- public T? Value => _isPresent
- ? _value
- : throw new InvalidOperationException("Cannot access Value of an absent Optional field");
-
- ///
- /// Gets the value if present, otherwise returns the fallback.
- ///
- /// The fallback value to return if absent.
- /// The value if present, otherwise the fallback.
- public T? GetValueOrDefault(T? fallback = default) => _isPresent ? _value : fallback;
-
- ///
- /// Creates an Optional representing an absent field.
- ///
- public static Optional Absent() => new(default, false);
-
- ///
- /// Creates an Optional representing a present field (may be null).
- ///
- /// The value (can be null for explicit JSON null).
- public static Optional Present(T? value) => new(value, true);
-
- ///
- public override string ToString() => _isPresent
- ? $"Present({_value})"
- : "Absent";
-}
diff --git a/src/Sendspin.SDK/Protocol/OptionalJsonConverter.cs b/src/Sendspin.SDK/Protocol/OptionalJsonConverter.cs
deleted file mode 100644
index 031e872..0000000
--- a/src/Sendspin.SDK/Protocol/OptionalJsonConverter.cs
+++ /dev/null
@@ -1,85 +0,0 @@
-using System.Text.Json;
-using System.Text.Json.Serialization;
-using System.Text.Json.Serialization.Metadata;
-
-namespace Sendspin.SDK.Protocol;
-
-///
-/// JSON converter factory for that distinguishes
-/// absent fields from explicit nulls.
-///
-///
-///
-/// In JSON, a field can be:
-///
-/// - Present with value: {"progress": {...}}
-/// - Present but null: {"progress": null}
-/// - Absent: {} (no progress field)
-///
-///
-///
-/// Standard C# nullable types collapse the latter two into null.
-/// This converter preserves the distinction using .
-///
-///
-public sealed class OptionalJsonConverterFactory : JsonConverterFactory
-{
- ///
- public override bool CanConvert(Type typeToConvert) =>
- typeToConvert.IsGenericType &&
- typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>);
-
- ///
- public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
- {
- var valueType = typeToConvert.GetGenericArguments()[0];
- var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType);
- return (JsonConverter)Activator.CreateInstance(converterType)!;
- }
-}
-
-///
-/// JSON converter for .
-///
-/// The type of the optional value.
-internal sealed class OptionalJsonConverter : JsonConverter>
-{
- ///
- public override Optional Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
- {
- // If we're reading, the field IS present in the JSON
- // (System.Text.Json only calls Read when the property exists)
- if (reader.TokenType == JsonTokenType.Null)
- {
- // Field is present with explicit null value
- return Optional.Present(default);
- }
-
- // Field is present with a value
- // Use JsonTypeInfo to avoid RequiresUnreferencedCode warning (AOT-friendly)
- var typeInfo = (JsonTypeInfo)options.GetTypeInfo(typeof(T));
- var value = JsonSerializer.Deserialize(ref reader, typeInfo);
- return Optional.Present(value);
- }
-
- ///
- public override void Write(Utf8JsonWriter writer, Optional value, JsonSerializerOptions options)
- {
- if (value.IsAbsent)
- {
- // Don't write anything - the field should be omitted
- // Note: This requires the containing object to use JsonIgnoreCondition
- // or custom serialization to actually omit the property
- return;
- }
-
- if (value.Value is null)
- {
- writer.WriteNullValue();
- }
- else
- {
- JsonSerializer.Serialize(writer, value.Value, options);
- }
- }
-}
diff --git a/src/Sendspin.SDK/README.md b/src/Sendspin.SDK/README.md
deleted file mode 100644
index c515eb2..0000000
--- a/src/Sendspin.SDK/README.md
+++ /dev/null
@@ -1,350 +0,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.
-
-[](https://www.nuget.org/packages/Sendspin.SDK/)
-[](https://github.com/chrisuthe/windowsSpin/blob/master/LICENSE)
-
-## 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 `PublishAot` and IL trimming for single-file native executables with no .NET runtime dependency
-- **Audio Device Switching**: Hot-switch audio output devices without interrupting playback
-
-## Installation
-
-```bash
-dotnet add package Sendspin.SDK
-```
-
-## Quick Start
-
-```csharp
-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());
-var clockSync = new KalmanClockSynchronizer(loggerFactory.CreateLogger());
-
-// 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(),
- 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 capabilities
-- `Sendspin.SDK.Connection` - WebSocket connection management
-- `Sendspin.SDK.Protocol` - Message types and serialization
-- `Sendspin.SDK.Synchronization` - Clock sync (Kalman filter)
-- `Sendspin.SDK.Audio` - Pipeline, buffer, decoders, and sync correction
-- `Sendspin.SDK.Discovery` - mDNS server discovery
-- `Sendspin.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
-
-```csharp
-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
-
-```csharp
-// 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:
-
-```csharp
-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:
-
-```csharp
-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:
-
-```csharp
-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.
-
-## NativeAOT Support
-
-Since v7.0.0, the SDK is fully compatible with [NativeAOT deployment](https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/) 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.
-
-```xml
-
-