From 7d2030599c677c6d90e835b68aac13fdb54902f1 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 7 Mar 2026 19:25:38 -0600 Subject: [PATCH] refactor: replace in-repo SDK source with Sendspin.SDK NuGet package 7.3.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK source has moved to Sendspin/sendspin-dotnet. This repo now consumes it as a NuGet package dependency instead of compiling it from source. - Replace ProjectReference with PackageReference (7.3.0) in both csproj files - Remove src/Sendspin.SDK/ directory (70+ source files) - Remove tests/Sendspin.SDK.Tests/ (SDK tests live in sendspin-dotnet now) - Remove SDK project and tests from solution file - Fix EntryDeadbandMicroseconds → DeadbandMicroseconds (renamed in 7.3.0) - Update CLAUDE.md to reflect SDK as external dependency --- SendspinClient.sln | 34 +- claude.md | 99 +- src/Sendspin.SDK/Audio/AudioDecoderFactory.cs | 55 - src/Sendspin.SDK/Audio/AudioPipeline.cs | 745 -------- src/Sendspin.SDK/Audio/Codecs/FlacDecoder.cs | 285 --- src/Sendspin.SDK/Audio/Codecs/OpusDecoder.cs | 88 - src/Sendspin.SDK/Audio/Codecs/PcmDecoder.cs | 99 -- .../Audio/Codecs/ThirdParty/SimpleFlac.cs | 596 ------- src/Sendspin.SDK/Audio/IAudioDecoder.cs | 38 - .../Audio/IAudioDecoderFactory.cs | 28 - src/Sendspin.SDK/Audio/IAudioPipeline.cs | 192 -- src/Sendspin.SDK/Audio/IAudioPlayer.cs | 194 --- src/Sendspin.SDK/Audio/IAudioSampleSource.cs | 29 - .../Audio/ISyncCorrectionProvider.cs | 116 -- src/Sendspin.SDK/Audio/ITimedAudioBuffer.cs | 372 ---- .../Audio/SyncCorrectionCalculator.cs | 358 ---- .../Audio/SyncCorrectionOptions.cs | 350 ---- src/Sendspin.SDK/Audio/TimedAudioBuffer.cs | 1537 ----------------- src/Sendspin.SDK/Client/ClientCapabilities.cs | 89 - src/Sendspin.SDK/Client/ClientRoles.cs | 63 - src/Sendspin.SDK/Client/ConnectionMode.cs | 25 - src/Sendspin.SDK/Client/ISendSpinClient.cs | 128 -- src/Sendspin.SDK/Client/SendSpinClient.cs | 1088 ------------ .../Client/SendSpinHostService.cs | 735 -------- .../Client/SyncOffsetEventArgs.cs | 30 - .../Connection/ConnectionOptions.cs | 49 - .../Connection/ConnectionState.cs | 48 - .../Connection/ISendSpinConnection.cs | 58 - .../Connection/IncomingConnection.cs | 214 --- .../Connection/SendSpinConnection.cs | 431 ----- .../Connection/SendSpinListener.cs | 131 -- .../Connection/SimpleWebSocketServer.cs | 242 --- .../Connection/WebSocketClientConnection.cs | 224 --- .../Diagnostics/DiagnosticAudioRingBuffer.cs | 194 --- .../Diagnostics/SyncMetricRingBuffer.cs | 138 -- .../Diagnostics/SyncMetricSnapshot.cs | 104 -- .../Discovery/DiscoveredServer.cs | 77 - .../Discovery/IServerDiscovery.cs | 47 - .../Discovery/MdnsServerDiscovery.cs | 318 ---- .../Discovery/MdnsServiceAdvertiser.cs | 272 --- src/Sendspin.SDK/Extensions/TaskExtensions.cs | 78 - src/Sendspin.SDK/MIGRATION-5.4.0.md | 213 --- src/Sendspin.SDK/MIGRATION-6.0.0.md | 352 ---- src/Sendspin.SDK/Models/AudioFormat.cs | 62 - src/Sendspin.SDK/Models/GroupState.cs | 77 - src/Sendspin.SDK/Models/PlaybackState.cs | 41 - src/Sendspin.SDK/Models/PlayerState.cs | 38 - src/Sendspin.SDK/Models/ServerInfo.cs | 49 - src/Sendspin.SDK/Models/TrackMetadata.cs | 110 -- .../Protocol/BinaryMessageParser.cs | 172 -- .../Protocol/MessageSerializer.cs | 112 -- .../Protocol/MessageSerializerContext.cs | 49 - .../Protocol/Messages/ClientCommandMessage.cs | 93 - .../Protocol/Messages/ClientGoodbyeMessage.cs | 39 - .../Protocol/Messages/ClientHelloMessage.cs | 207 --- .../Protocol/Messages/ClientStateMessage.cs | 131 -- .../Messages/ClientSyncOffsetMessage.cs | 129 -- .../Protocol/Messages/ClientTimeMessage.cs | 68 - .../Protocol/Messages/GroupUpdateMessage.cs | 61 - .../Protocol/Messages/IMessage.cs | 58 - .../Protocol/Messages/MessageTypes.cs | 62 - .../Protocol/Messages/ServerCommandMessage.cs | 53 - .../Protocol/Messages/ServerHelloMessage.cs | 65 - .../Protocol/Messages/ServerStateMessage.cs | 128 -- .../Protocol/Messages/ServerTimeMessage.cs | 63 - .../Protocol/Messages/StreamClearMessage.cs | 47 - .../Protocol/Messages/StreamEndMessage.cs | 47 - .../Messages/StreamRequestFormatMessage.cs | 26 - .../Protocol/Messages/StreamStartMessage.cs | 41 - src/Sendspin.SDK/Protocol/Optional.cs | 64 - .../Protocol/OptionalJsonConverter.cs | 85 - src/Sendspin.SDK/README.md | 350 ---- src/Sendspin.SDK/Sendspin.SDK.csproj | 455 ----- .../Synchronization/HighPrecisionTimer.cs | 103 -- .../KalmanClockSynchronizer.cs | 557 ------ .../Synchronization/MonotonicTimer.cs | 215 --- src/Sendspin.SDK/update-namespaces.ps1 | 8 - .../Audio/WasapiAudioPlayer.cs | 2 +- .../SendspinClient.Services.csproj | 2 +- src/SendspinClient/SendspinClient.csproj | 2 +- .../Connection/SimpleWebSocketServerTests.cs | 117 -- .../Protocol/MessageSerializerTests.cs | 94 - .../Protocol/OptionalJsonConverterTests.cs | 53 - .../Sendspin.SDK.Tests.csproj | 25 - 84 files changed, 27 insertions(+), 14696 deletions(-) delete mode 100644 src/Sendspin.SDK/Audio/AudioDecoderFactory.cs delete mode 100644 src/Sendspin.SDK/Audio/AudioPipeline.cs delete mode 100644 src/Sendspin.SDK/Audio/Codecs/FlacDecoder.cs delete mode 100644 src/Sendspin.SDK/Audio/Codecs/OpusDecoder.cs delete mode 100644 src/Sendspin.SDK/Audio/Codecs/PcmDecoder.cs delete mode 100644 src/Sendspin.SDK/Audio/Codecs/ThirdParty/SimpleFlac.cs delete mode 100644 src/Sendspin.SDK/Audio/IAudioDecoder.cs delete mode 100644 src/Sendspin.SDK/Audio/IAudioDecoderFactory.cs delete mode 100644 src/Sendspin.SDK/Audio/IAudioPipeline.cs delete mode 100644 src/Sendspin.SDK/Audio/IAudioPlayer.cs delete mode 100644 src/Sendspin.SDK/Audio/IAudioSampleSource.cs delete mode 100644 src/Sendspin.SDK/Audio/ISyncCorrectionProvider.cs delete mode 100644 src/Sendspin.SDK/Audio/ITimedAudioBuffer.cs delete mode 100644 src/Sendspin.SDK/Audio/SyncCorrectionCalculator.cs delete mode 100644 src/Sendspin.SDK/Audio/SyncCorrectionOptions.cs delete mode 100644 src/Sendspin.SDK/Audio/TimedAudioBuffer.cs delete mode 100644 src/Sendspin.SDK/Client/ClientCapabilities.cs delete mode 100644 src/Sendspin.SDK/Client/ClientRoles.cs delete mode 100644 src/Sendspin.SDK/Client/ConnectionMode.cs delete mode 100644 src/Sendspin.SDK/Client/ISendSpinClient.cs delete mode 100644 src/Sendspin.SDK/Client/SendSpinClient.cs delete mode 100644 src/Sendspin.SDK/Client/SendSpinHostService.cs delete mode 100644 src/Sendspin.SDK/Client/SyncOffsetEventArgs.cs delete mode 100644 src/Sendspin.SDK/Connection/ConnectionOptions.cs delete mode 100644 src/Sendspin.SDK/Connection/ConnectionState.cs delete mode 100644 src/Sendspin.SDK/Connection/ISendSpinConnection.cs delete mode 100644 src/Sendspin.SDK/Connection/IncomingConnection.cs delete mode 100644 src/Sendspin.SDK/Connection/SendSpinConnection.cs delete mode 100644 src/Sendspin.SDK/Connection/SendSpinListener.cs delete mode 100644 src/Sendspin.SDK/Connection/SimpleWebSocketServer.cs delete mode 100644 src/Sendspin.SDK/Connection/WebSocketClientConnection.cs delete mode 100644 src/Sendspin.SDK/Diagnostics/DiagnosticAudioRingBuffer.cs delete mode 100644 src/Sendspin.SDK/Diagnostics/SyncMetricRingBuffer.cs delete mode 100644 src/Sendspin.SDK/Diagnostics/SyncMetricSnapshot.cs delete mode 100644 src/Sendspin.SDK/Discovery/DiscoveredServer.cs delete mode 100644 src/Sendspin.SDK/Discovery/IServerDiscovery.cs delete mode 100644 src/Sendspin.SDK/Discovery/MdnsServerDiscovery.cs delete mode 100644 src/Sendspin.SDK/Discovery/MdnsServiceAdvertiser.cs delete mode 100644 src/Sendspin.SDK/Extensions/TaskExtensions.cs delete mode 100644 src/Sendspin.SDK/MIGRATION-5.4.0.md delete mode 100644 src/Sendspin.SDK/MIGRATION-6.0.0.md delete mode 100644 src/Sendspin.SDK/Models/AudioFormat.cs delete mode 100644 src/Sendspin.SDK/Models/GroupState.cs delete mode 100644 src/Sendspin.SDK/Models/PlaybackState.cs delete mode 100644 src/Sendspin.SDK/Models/PlayerState.cs delete mode 100644 src/Sendspin.SDK/Models/ServerInfo.cs delete mode 100644 src/Sendspin.SDK/Models/TrackMetadata.cs delete mode 100644 src/Sendspin.SDK/Protocol/BinaryMessageParser.cs delete mode 100644 src/Sendspin.SDK/Protocol/MessageSerializer.cs delete mode 100644 src/Sendspin.SDK/Protocol/MessageSerializerContext.cs delete mode 100644 src/Sendspin.SDK/Protocol/Messages/ClientCommandMessage.cs delete mode 100644 src/Sendspin.SDK/Protocol/Messages/ClientGoodbyeMessage.cs delete mode 100644 src/Sendspin.SDK/Protocol/Messages/ClientHelloMessage.cs delete mode 100644 src/Sendspin.SDK/Protocol/Messages/ClientStateMessage.cs delete mode 100644 src/Sendspin.SDK/Protocol/Messages/ClientSyncOffsetMessage.cs delete mode 100644 src/Sendspin.SDK/Protocol/Messages/ClientTimeMessage.cs delete mode 100644 src/Sendspin.SDK/Protocol/Messages/GroupUpdateMessage.cs delete mode 100644 src/Sendspin.SDK/Protocol/Messages/IMessage.cs delete mode 100644 src/Sendspin.SDK/Protocol/Messages/MessageTypes.cs delete mode 100644 src/Sendspin.SDK/Protocol/Messages/ServerCommandMessage.cs delete mode 100644 src/Sendspin.SDK/Protocol/Messages/ServerHelloMessage.cs delete mode 100644 src/Sendspin.SDK/Protocol/Messages/ServerStateMessage.cs delete mode 100644 src/Sendspin.SDK/Protocol/Messages/ServerTimeMessage.cs delete mode 100644 src/Sendspin.SDK/Protocol/Messages/StreamClearMessage.cs delete mode 100644 src/Sendspin.SDK/Protocol/Messages/StreamEndMessage.cs delete mode 100644 src/Sendspin.SDK/Protocol/Messages/StreamRequestFormatMessage.cs delete mode 100644 src/Sendspin.SDK/Protocol/Messages/StreamStartMessage.cs delete mode 100644 src/Sendspin.SDK/Protocol/Optional.cs delete mode 100644 src/Sendspin.SDK/Protocol/OptionalJsonConverter.cs delete mode 100644 src/Sendspin.SDK/README.md delete mode 100644 src/Sendspin.SDK/Sendspin.SDK.csproj delete mode 100644 src/Sendspin.SDK/Synchronization/HighPrecisionTimer.cs delete mode 100644 src/Sendspin.SDK/Synchronization/KalmanClockSynchronizer.cs delete mode 100644 src/Sendspin.SDK/Synchronization/MonotonicTimer.cs delete mode 100644 src/Sendspin.SDK/update-namespaces.ps1 delete mode 100644 tests/Sendspin.SDK.Tests/Connection/SimpleWebSocketServerTests.cs delete mode 100644 tests/Sendspin.SDK.Tests/Protocol/MessageSerializerTests.cs delete mode 100644 tests/Sendspin.SDK.Tests/Protocol/OptionalJsonConverterTests.cs delete mode 100644 tests/Sendspin.SDK.Tests/Sendspin.SDK.Tests.csproj 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. - -[![NuGet](https://img.shields.io/nuget/v/Sendspin.SDK.svg)](https://www.nuget.org/packages/Sendspin.SDK/) -[![GitHub](https://img.shields.io/github/license/chrisuthe/windowsSpin)](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 - - true - -``` - -```bash -dotnet publish -c Release -r linux-x64 -# Produces a single native binary (~15-25MB depending on dependencies) -``` - -**How it works**: The SDK uses source-generated `System.Text.Json` serialization (no runtime reflection) and built-in .NET WebSocket APIs. All public types are annotated with `IsAotCompatible` and `IsTrimmable` to ensure the .NET build analyzers catch any regressions. - -**Your code**: If your `IAudioPlayer` implementation also avoids reflection, the entire stack will be AOT-safe. Most audio libraries (SDL2, OpenAL, PipeWire bindings) work fine with NativeAOT. - -## Migration Guide - -### Upgrading to v7.0.0 - -**Breaking change**: `SendspinListener.ServerConnected` event parameter type changed. - -```csharp -// Before (v6.x): -listener.ServerConnected += (sender, fleckConnection) => { /* Fleck.IWebSocketConnection */ }; - -// After (v7.0+): -listener.ServerConnected += (sender, wsConnection) => { /* WebSocketClientConnection */ }; -``` - -No changes needed if you only use `SendspinHostService` or `SendspinClientService` (most consumers). - -### Upgrading to v5.0.0 - -**Breaking change**: Sync correction is now external. The SDK reports error; you apply correction. - -**Before (v4.x and earlier):** -```csharp -// SDK applied correction internally -var read = buffer.Read(samples, currentTime); -buffer.TargetPlaybackRateChanged += rate => resampler.Rate = rate; -``` - -**After (v5.0+):** -```csharp -// Create correction provider -var correctionProvider = new SyncCorrectionCalculator( - SyncCorrectionOptions.Default, sampleRate, channels); - -// Read raw samples (no internal correction) -var read = buffer.ReadRaw(samples, offset, count, currentTime); - -// Update and apply correction externally -correctionProvider.UpdateFromSyncError( - buffer.SyncErrorMicroseconds, - buffer.SmoothedSyncErrorMicroseconds); - -// Subscribe to rate changes -correctionProvider.CorrectionChanged += p => resampler.Rate = p.TargetPlaybackRate; - -// Notify buffer of any drops/inserts for accurate tracking -buffer.NotifyExternalCorrection(droppedCount, insertedCount); -``` - -**Benefits:** -- Browser apps can use native `playbackRate` (WSOLA) -- Windows apps can choose WDL resampler, SoundTouch, or drop/insert -- Linux apps can use ALSA hardware rate adjustment -- Testability: correction logic is isolated - -### Upgrading to v3.0.0 - -**Breaking change**: `IClockSynchronizer` requires `HasMinimalSync` property. - -```csharp -// Add to custom IClockSynchronizer implementations: -public bool HasMinimalSync => MeasurementCount >= 2; -``` - -### Upgrading to v2.0.0 - -1. **`HardwareLatencyMs` removed** - No action needed, latency handled automatically -2. **`IAudioPipeline.SwitchDeviceAsync()` required** - Implement for device switching -3. **`IAudioPlayer.SwitchDeviceAsync()` required** - Implement in your audio player - -## Example Projects - -See the [Windows client](https://github.com/chrisuthe/windowsSpin/tree/master/src/SendspinClient) for a complete WPF implementation using NAudio/WASAPI with external sync correction. - -## License - -MIT License - see [LICENSE](https://github.com/chrisuthe/windowsSpin/blob/master/LICENSE) for details. diff --git a/src/Sendspin.SDK/Sendspin.SDK.csproj b/src/Sendspin.SDK/Sendspin.SDK.csproj deleted file mode 100644 index 57e5f3c..0000000 --- a/src/Sendspin.SDK/Sendspin.SDK.csproj +++ /dev/null @@ -1,455 +0,0 @@ - - - - net8.0;net10.0 - enable - enable - latest - - - Sendspin.SDK - 7.2.1 - Sendspin Contributors - Sendspin - Sendspin SDK - Cross-platform SDK for the Sendspin synchronized multi-room audio protocol. Connect to Music Assistant servers and play synchronized audio across multiple devices. - sendspin;audio;multiroom;sync;music-assistant;streaming - https://github.com/chrisuthe/windowsSpin - https://github.com/chrisuthe/windowsSpin - git - MIT - README.md - -v7.2.1 - Buffer Overrun Sync Fix: - -Bug Fixes: -- Fixed multi-player sync catastrophe where buffer overruns during the server's initial - audio burst destroyed the beginning of the stream. Compact codecs (OPUS) can burst 40+ - seconds of audio in seconds on LAN, causing 1700+ overruns that advanced the player - 20-35 seconds into the song while other players started from the beginning. -- Pre-playback overrun safety net: When the buffer fills before playback starts, incoming - audio is now discarded instead of dropping the oldest. This preserves the stream's - starting position so all players begin at the same point in the song. -- SkipStaleAudio: When a player's pipeline initializes late and the scheduled start time - is already in the past, the buffer now skips forward through stale segments to the - correct playback position. Previously, the player would start from the beginning of - the buffer regardless of timestamps, causing permanent offset vs other players. -- Updated ClientCapabilities.BufferCapacity doc: Clarified that apps should derive this - from actual PCM buffer duration and worst-case codec bitrate instead of using the - hardcoded 32MB default. - -v7.2.0 - Reconnect Resilience, Freeze/Thaw Kalman State, FLAC Fixes: - -New Features: -- IClockSynchronizer.Freeze()/Thaw()/IsFrozen: Snapshot and restore Kalman filter state - across reconnects. On disconnect, Freeze() saves converged offset/drift estimates. - On reconnect, Thaw() restores them with 10x inflated covariance for fast adaptation. - Converges in 1-2 measurements instead of 5+ from scratch. Matches Android client. -- Reconnect stabilization period: Suppresses sync corrections for 2 seconds after - WebSocket reconnect while the Kalman filter re-converges. Prevents audible artifacts - from unreliable sync error measurements. Matches Android client behavior. -- SyncCorrectionOptions.ReconnectStabilizationMicroseconds: Configurable duration - (default: 2,000,000 = 2 seconds) -- Reanchor cooldown: 5-second minimum between re-anchors, matching Android/Python CLI. - Prevents re-anchor loops on track change. -- SyncCorrectionOptions.ReanchorCooldownMicroseconds: Configurable cooldown duration - (default: 5,000,000 = 5 seconds) -- ISyncCorrectionProvider.NotifyReconnect(): Default interface method for reconnect notification -- IAudioPlayer.NotifyReconnect(): Default interface method for player reconnect notification -- IAudioPipeline.NotifyReconnect(): Pipeline-level reconnect notification -- ITimedAudioBuffer.NotifyReconnect(): Buffer-level reconnect notification - -Bug Fixes: -- Fixed FLAC 32-bit silence: Use actual STREAMINFO bit depth instead of stream/start header. - The stream/start message may report incorrect bit depth for FLAC streams; the decoder now - reads the true bit depth from the FLAC STREAMINFO metadata block. -- Fixed AudioFormat.BitDepth not updating from decoded FLAC STREAMINFO. -- Fixed artwork-only stream/start causing unnecessary pipeline start. stream/start messages - with no "player" key are now correctly skipped. -- Increased default buffer capacity from 8s to 30s to prevent overruns during server's - initial audio burst on track start. - -BREAKING (minor): -- IClockSynchronizer: Added Freeze(), Thaw(), IsFrozen members. - If you implement IClockSynchronizer directly (not using KalmanClockSynchronizer), add these. -- ITimedAudioBuffer: Added NotifyReconnect() method (no default implementation). - If you implement ITimedAudioBuffer directly (not using TimedAudioBuffer), add this method. -- Most consumers are unaffected as the built-in implementations are the only known ones. - -v7.1.1 - Documentation: -- Added NativeAOT support section to README with PublishAot usage and guidance -- Added v7.0.0 migration guide for SendspinListener.ServerConnected breaking change -- Listed NativeAOT & trimming compatibility in Features section - -v7.1.0 - Protocol Compliance (aiosendspin audit): - -New Features: -- PlayerStatePayload.StaticDelayMs: Reports configured static delay to server - for GroupSync calibration. Server uses this to compensate for device output - latency when calculating group-wide sync offsets. -- ISendspinClient.ArtworkCleared event: Raised when server sends empty artwork - binary payload (type 8-11, 0-byte data) to signal "no artwork available". - Previously, empty payloads were passed through as empty byte arrays. -- SendPlayerStateAsync now accepts optional staticDelayMs parameter (default 0.0) -- SendspinHostService.ArtworkCleared event: Forwarded from child clients - -Bug Fixes: -- Empty artwork binary messages no longer treated as valid image data - -v7.0.0 - NativeAOT Support: - -BREAKING CHANGES: -- SendspinListener.ServerConnected event: parameter type changed from - Fleck.IWebSocketConnection to Sendspin.SDK.Connection.WebSocketClientConnection -- Fleck NuGet dependency removed (replaced with built-in .NET WebSocket APIs) - -New Features: -- Full NativeAOT compatibility: SDK can be used in NativeAOT-published applications -- JSON source generation: System.Text.Json uses compile-time source generation - instead of runtime reflection (also improves startup performance) -- Built-in WebSocket server: TcpListener + WebSocket.CreateFromStream() replaces - Fleck for server-initiated connections (no admin privileges, fully AOT-safe) -- IsAotCompatible and IsTrimmable properties enabled - -Migration: -- If you subscribe to SendspinListener.ServerConnected directly, update the event - handler parameter type from IWebSocketConnection to WebSocketClientConnection -- If you reference Fleck types through the SDK, switch to WebSocketClientConnection -- No changes needed if you only use SendspinHostService or SendspinClient - -v6.3.4 - AudioBufferStats.TimingSourceName Fix: -- Fixed: AudioBufferStats.TimingSourceName property now included in GetStats() output -- Enables "Stats for Nerds" UI to display active timing source (audio-clock, monotonic, wall-clock) -- Note: This property was documented in 6.3.3 release notes but accidentally omitted from that build - -v6.3.3 - Categorized Diagnostic Logging: -- All log messages now include category prefixes for easy filtering: - - [ClockSync] - Clock synchronization, drift, static delay - - [Timing] - Timing source selection and transitions - - [Playback] - Pipeline start/stop, startup latency - - [SyncError] - Periodic sync status monitoring - - [Correction] - Frame drop/insert correction events - - [Buffer] - Buffer overrun/underrun events -- Added timing source transition logging: logs when audio clock becomes available/unavailable -- Enhanced startup latency visibility: logs at Info level when non-zero -- Added AudioBufferStats.TimingSourceName property for UI "Stats for Nerds" display -- Example categorized output: - "[ClockSync] Converged after 5 measurements" - "[Timing] Source changed: monotonic → audio-clock" - "[Playback] Starting playback: buffer=500ms, sync offset=+12.3ms" - "[Correction] Started: DROPPING (syncError=+45ms, timing=audio-clock)" - -v6.3.2 - Timing Source Visibility: -- Added ITimedAudioBuffer.TimingSourceName property to track active timing source -- Sync correction logs now include timing source (audio-clock, monotonic, wall-clock) -- AudioPipeline sets timing source name on buffer for consistent logging across components -- Improved logging clarity: MonotonicTimer stats only shown when it's the active source -- Skip MonotonicTimer reset on Clear() when audio clock is active (unnecessary operation) -- Example log output: - "Sync correction started: DROPPING (..., timing=audio-clock)" - "Sync correction ended: DROPPING complete (..., timing=monotonic)" - -v6.3.1 - Sync Correction Diagnostic Logging: -- Added logging for sync correction mode transitions (None↔Dropping, None↔Inserting) -- Logs include: session counts, cumulative totals, duration, sync error values at transition -- Helps diagnose what triggers large frame drop/insert corrections in VM environments -- Example log output: - "Sync correction started: DROPPING (syncError=+45.32ms, smoothed=+42.18ms, dropEveryN=480, elapsed=5000ms)" - "Sync correction ended: DROPPING complete (dropped=11934 session, 11934 total, duration=850ms)" - -v6.3.0 - Hybrid Audio Clock Support: -New Features: -- Added optional audio hardware clock support for VM-immune sync timing - - IAudioPlayer.GetAudioClockMicroseconds(): Optional method returning hardware clock time - - Default implementation returns null (backward compatible - no code changes needed) - - AudioPipeline automatically prefers audio clock over MonotonicTimer when available -- Platform implementers can now override with their audio backend's hardware clock: - - PulseAudio: pa_stream_get_time() - - ALSA: snd_pcm_htimestamp() - - PortAudio: outputBufferDacTime - - CoreAudio: AudioTimeStamp.mHostTime -- Audio hardware clocks are immune to VM wall clock issues (hypervisor scheduling, time sync) -- Includes MonotonicTimer diagnostics from v6.2.0-preview.2 - -Backward Compatibility: -- Existing apps require ZERO code changes - MonotonicTimer fallback is automatic -- Only apps needing VM immunity need to implement GetAudioClockMicroseconds() - -v6.2.0-preview.2 - MonotonicTimer Diagnostics: -- Added telemetry counters to MonotonicTimer for diagnosing timer behavior in VMs - - TotalCalls, BackwardJumpCount, ForwardJumpCount - - TotalBackwardJumpMicroseconds, TotalForwardJumpClampedMicroseconds - - MaxBackwardJumpMicroseconds, MaxForwardJumpMicroseconds - - GetStatsSummary() for formatted stats display -- MonotonicTimer now automatically reset on AudioPipeline.Clear() to avoid stale state -- Enhanced sync drift logging includes timer stats when error exceeds 50ms -- Reset() now accepts optional resetTelemetry parameter - -v6.2.0-preview.1 - VM-Resilient Timing: -- Added MonotonicTimer wrapper for VM environments where wall clock can jump erratically -- Enforces monotonicity (never returns decreasing values) and clamps forward jumps to 50ms max -- Enabled by default in AudioPipeline - all SDK consumers automatically benefit -- No code changes required for existing consumers - -v6.1.1 - Logging Improvements: -- Changed clock sync telemetry from Information to Debug log level -- Reduces log noise in production environments (offset/drift measurements are internal details) - -v6.1.0 - Track End State Fix: - -Bug Fixes: -- Fixed track end not clearing progress display (issue: UI showed stuck at final position with play button) - - Server sends progress=null to signal track ended, but SDK was keeping the old progress value - - Root cause: null-coalescing merge (meta.Progress ?? existing.Progress) couldn't distinguish - "field absent" (partial update - keep existing) from "field is null" (track ended - clear progress) - -New Features: -- Optional<T> type: Distinguishes JSON field absent from explicit null - - Optional.IsPresent: Field was in the JSON (value may be null) - - Optional.IsAbsent: Field was not in the JSON - - Used for ServerMetadata.Progress to properly handle track end signal -- OptionalJsonConverterFactory: System.Text.Json converter for Optional<T> - -BREAKING (compile-time only, minor): -- ServerMetadata.Progress type changed from PlaybackProgress? to Optional<PlaybackProgress?> - - Most consumers use GroupState.Metadata.Progress (unchanged, still PlaybackProgress?) - - Only affects code that directly accesses ServerMetadata from ServerStateMessage - -Migration: -- If you directly access ServerMetadata.Progress: - OLD: if (meta.Progress != null) { ... meta.Progress.TrackProgress ... } - NEW: if (meta.Progress.IsPresent && meta.Progress.Value != null) { ... meta.Progress.Value.TrackProgress ... } -- GroupState.Metadata.Progress usage is unchanged (SDK handles the Optional internally) - -v6.0.0 - Spec Compliance Overhaul: - -This release brings the SDK into alignment with the official Sendspin protocol specification -(https://www.sendspin-audio.com/spec/). Non-spec extensions have been removed, missing fields -added, and naming mismatches corrected. - -BREAKING CHANGES: - -GroupUpdatePayload (group/update message): -- REMOVED: Volume, Muted, Metadata, Position, Shuffle, Repeat -- Per spec, group/update only contains: group_id, group_name, playback_state -- Volume/mute comes via server/state controller object -- Metadata comes via server/state metadata object - -ServerHelloPayload (server/hello message): -- REMOVED: GroupId (not in spec) -- REMOVED: ProtocolVersion (string) - replaced by Version (int) -- REMOVED: Support dictionary (not in spec) -- ADDED: Version property (int, must be 1) - -StreamStartPayload (stream/start message): -- REMOVED: StreamId (not in spec) -- REMOVED: TargetTimestamp (not in spec) - -TrackMetadata (server/state metadata): -- REMOVED: ArtworkUri (not in spec - only ArtworkUrl exists) -- REMOVED: Uri (not in spec) -- REMOVED: MediaType (not in spec) -- CHANGED: Duration and Position are now READ-ONLY computed properties - - Duration = Progress?.TrackDuration / 1000.0 (seconds from ms) - - Position = Progress?.TrackProgress / 1000.0 (seconds from ms) -- ADDED: Timestamp (server timestamp in microseconds) -- ADDED: AlbumArtist (may differ from Artist on compilations) -- ADDED: Year (release year) -- ADDED: Track (track number on album) -- ADDED: Progress (PlaybackProgress object with TrackProgress, TrackDuration, PlaybackSpeed) -- ADDED: Repeat (string: "off", "one", "all") -- ADDED: Shuffle (bool) - -ClientHelloPayload (client/hello message): -- FIXED: Property name changed from "player_support" to "player@v1_support" per spec - -RETAINED EXTENSIONS (documented): -These SDK extensions are kept intentionally and documented as non-spec: -- client/sync_offset, client/sync_offset_ack: GroupSync acoustic calibration support -- PlayerStatePayload.BufferLevel: Diagnostic buffer level reporting -- PlayerStatePayload.Error: Error message reporting - -DOCUMENTATION: -- GroupState: Updated XML docs clarifying field sources: - - GroupId, Name, PlaybackState from group/update - - Volume, Muted from server/state controller - - Metadata, Shuffle, Repeat from server/state metadata - -Migration Guide: -- If you accessed GroupUpdatePayload.Volume/Muted/Metadata, use GroupState which aggregates - data from both group/update and server/state messages -- If you set TrackMetadata.Duration/Position directly, set Progress object instead -- If you used TrackMetadata.Uri/ArtworkUri, these were non-spec and have been removed -- If you accessed ServerHelloPayload.ProtocolVersion, use Version (int) instead - -v5.4.1 - Group Handling Fixes: -Bug Fixes: -- Fixed GroupId not updating when player switches groups - - HandleGroupUpdate() used null-coalescing assignment (??=) which only set GroupId when creating - a new GroupState object, never updating it on subsequent group/update messages - - Now always updates GroupId when non-empty, allowing proper group switching behavior -- Added group_name field to GroupUpdatePayload (per Sendspin spec) - - GroupState.Name is now populated from server's group_name field - - Enables proper display of group names in UI (e.g., Switch Group button tooltip) - -Impact: -- Fixes group switching functionality for players moving between groups -- GroupState.Name now contains the friendly group name when provided by server - -v5.4.0 - Player Volume Separation & Server Command ACK: -BREAKING CHANGES: -- GroupState.Volume and GroupState.Muted now represent the GROUP AVERAGE (display only) - - Previously used for both group display and player audio control - - Player volume/mute is now tracked separately via PlayerState - -New Features: -- PlayerState model: Represents this player's own volume (0-100) and mute state -- PlayerStateChanged event: Fires when server changes player volume/mute via server/command -- CurrentPlayerState property: Access current player volume/mute state -- Automatic ACK: SDK sends client/state acknowledgement after applying server/command - - Fixes spec compliance where server didn't know player applied volume changes - - Enables correct group volume calculations for multi-player scenarios -- ClientCapabilities.InitialVolume/InitialMuted: Set player's initial state on connection - -Migration: -- Subscribe to PlayerStateChanged (not GroupStateChanged) for volume/mute from server -- GroupStateChanged still used for playback state, metadata, track info -- See MIGRATION-5.4.0.md for detailed migration guide and code examples - -Impact: -- Fixes group volume issues where players at different levels (15%, 45%) incorrectly - applied group average (30%) to their own audio -- Now correctly ignores group average and only responds to server/command targeting - this specific player - -v5.3.0 - Artwork Support Fix & Discovery Improvements: -Bug Fixes: -- Fixed artwork@v1_support format to match Sendspin spec - - Property name: artwork_support → artwork@v1_support - - Structure: ArtworkSupport.Channels is now List<ArtworkChannelSpec> - - Previous format caused handshake failures with spec-compliant servers - -New Features: -- Added ArtworkChannelSpec class for artwork channel configuration - - Source: "album" | "artist" | "none" - - Format: "jpeg" | "png" | "bmp" - - MediaWidth/MediaHeight: max dimensions in pixels -- Added MdnsServerDiscovery.IsDiscovering property - -BREAKING (compile-time only): -- ArtworkSupport.Channels type changed from int to List<ArtworkChannelSpec> -- ArtworkSupport.SupportedFormats and MaxSize removed (use ArtworkChannelSpec) - -v5.2.2 - Enhanced 3-Point Interpolation: -Improvements: -- Upgraded sync correction from 2-point to 3-point weighted interpolation - - DROP: 0.25*lastOutput + 0.5*frameA + 0.25*frameB (was: (A+B)*0.5) - - INSERT: 0.25*lastOutput + 0.5*nextFrame + 0.25*futureFrame (was: (last+next)*0.5) -- Added PeekSamplesFromBufferAtOffset() for looking ahead multiple frames -- INSERT now peeks 2 frames ahead for better curve fitting - -Impact: -- Smoother transitions during sync corrections, especially for low-frequency content -- Better phase continuity through edit points -- Falls back to 2-point if insufficient frames available - -v5.2.1 - Sync Correction Audio Quality: -Bug Fixes: -- Fixed audible clicks/pops during frame drop/insert sync corrections - - Drop operations now blend adjacent frames: (frameA + frameB) * 0.5 - - Insert operations now interpolate: (lastOutput + nextInput) * 0.5 - - Previously used raw frame repetition causing waveform discontinuities -- Added PeekSamplesFromBuffer() helper for non-consuming buffer reads - -Impact: -- Sync corrections (when error exceeds 15ms) are now inaudible -- Smoother audio during clock drift recovery -- No API changes - drop-in replacement for 5.2.0 - -v5.2.0 - Client Port Change (Spec Alignment): -BREAKING CHANGE: -- Default client listening port changed from 8927 to 8928 - - Aligns with Sendspin spec update (Sendspin/spec#62) - - Servers: 8927 (_sendspin-server._tcp) - - Clients: 8928 (_sendspin._tcp) - - This allows servers and clients to coexist on the same machine without port conflicts - -Affected Classes: -- ListenerOptions.Port: Default changed from 8927 to 8928 -- AdvertiserOptions.Port: Default changed from 8927 to 8928 - -Migration: -- If your server expects clients on port 8927, update to discover/connect on 8928 -- Or explicitly configure ListenerOptions/AdvertiserOptions to use 8927 for backward compatibility - -v5.1.1 - Volume Handling Fix: -Bug Fixes: -- Fixed incorrect application of group volume to audio pipeline - - HandleGroupUpdate and HandleServerState were applying GROUP volume (average of all players) - to the local audio output, overriding player-specific volume settings - - Per Sendspin spec: only server/command contains PLAYER volume to apply locally - - group/update and server/state contain GROUP volume for UI display only -- This prevents volume sync issues in multi-player groups where each player has different - volume levels (e.g., 60%, 80%, 100%) - previously all players would jump to group average - -Impact: -- Multi-player groups now correctly maintain individual player volumes -- server/command remains the only message that applies volume to audio pipeline -- UI state (_currentGroup.Volume) still updated for display purposes - -v5.1.0 - Faster Playback Startup: -Performance: -- Reduced audio startup delay from ~1000ms to ~200-300ms - - Sync burst no longer blocks pipeline initialization - - Smart burst skip: skips re-sync if clock already converged - - Zero chunk loss during decoder/buffer initialization - -New Features: -- IAudioPipeline.IsReady: Check if pipeline can accept audio chunks -- Pre-buffer queue: Captures early chunks during initialization, drains after startup - -Implementation Details: -- SendTimeSyncBurstAsync now fire-and-forget (doesn't block StartAsync) -- Early chunks queued in ConcurrentQueue (max 100 chunks, ~2 seconds) -- Queue drained after pipeline.StartAsync() completes -- Queue cleared on stream/start and stream/end - -Scenarios improved: -- First play after connection: No longer waits 450ms for sync burst -- Pause/resume: Near-instant resume when clock already synced -- Track changes: Faster transitions between tracks - -For older release notes (v5.0.0 and earlier), see https://github.com/chrisuthe/windowsSpin/releases - - Copyright © 2024-2026 Sendspin Contributors - - - true - true - true - snupkg - - - true - $(NoWarn);CS1591 - - - true - true - - - - - - - - - - - - - - - - - diff --git a/src/Sendspin.SDK/Synchronization/HighPrecisionTimer.cs b/src/Sendspin.SDK/Synchronization/HighPrecisionTimer.cs deleted file mode 100644 index 28fa9c6..0000000 --- a/src/Sendspin.SDK/Synchronization/HighPrecisionTimer.cs +++ /dev/null @@ -1,103 +0,0 @@ -// -// Licensed under the MIT License. See LICENSE file in the project root. -// - -using System.Diagnostics; - -namespace Sendspin.SDK.Synchronization; - -/// -/// Provides high-precision time measurement using hardware performance counters. -/// -/// -/// -/// This class provides microsecond-precision timing by combining: -/// - System UTC time for absolute reference (synchronized once at startup) -/// - Stopwatch for precise incremental timing between measurements -/// -/// -/// Why not use DateTimeOffset.UtcNow directly? -/// - DateTime resolution on Windows is typically ~15ms (system timer interrupt) -/// - Stopwatch uses QueryPerformanceCounter with ~100ns precision -/// - For audio synchronization, we need microsecond-level accuracy -/// -/// -public sealed class HighPrecisionTimer : IHighPrecisionTimer -{ - private readonly long _startTimestampTicks; - private readonly long _startTimeUnixMicroseconds; - private readonly double _ticksToMicroseconds; - - /// - /// Gets the shared instance of the high-precision timer. - /// - /// - /// Using a singleton ensures all components share the same time base, - /// avoiding drift between different timer instances. - /// - public static IHighPrecisionTimer Shared { get; } = new HighPrecisionTimer(); - - /// - /// Initializes a new instance of the class. - /// - public HighPrecisionTimer() - { - // Capture both Stopwatch and system time as close together as possible - // to minimize the offset between them - _startTimestampTicks = Stopwatch.GetTimestamp(); - _startTimeUnixMicroseconds = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1000; - - // Pre-calculate conversion factor for ticks to microseconds - // Stopwatch.Frequency = ticks per second - _ticksToMicroseconds = 1_000_000.0 / Stopwatch.Frequency; - } - - /// - public long GetCurrentTimeMicroseconds() - { - var currentTicks = Stopwatch.GetTimestamp(); - var elapsedTicks = currentTicks - _startTimestampTicks; - var elapsedMicroseconds = (long)(elapsedTicks * _ticksToMicroseconds); - - return _startTimeUnixMicroseconds + elapsedMicroseconds; - } - - /// - public long GetElapsedMicroseconds(long fromTimeMicroseconds) - { - return GetCurrentTimeMicroseconds() - fromTimeMicroseconds; - } - - /// - /// Gets the resolution of the timer in nanoseconds. - /// - /// Timer resolution in nanoseconds. - public static double GetResolutionNanoseconds() - { - return 1_000_000_000.0 / Stopwatch.Frequency; - } - - /// - /// Gets whether the underlying hardware supports high-resolution timing. - /// - public static bool IsHighResolution => Stopwatch.IsHighResolution; -} - -/// -/// Interface for high-precision time measurement. -/// -public interface IHighPrecisionTimer -{ - /// - /// Gets the current time in microseconds since Unix epoch. - /// - /// Current time in microseconds. - long GetCurrentTimeMicroseconds(); - - /// - /// Gets the elapsed time since a given timestamp. - /// - /// Starting time in microseconds. - /// Elapsed time in microseconds. - long GetElapsedMicroseconds(long fromTimeMicroseconds); -} diff --git a/src/Sendspin.SDK/Synchronization/KalmanClockSynchronizer.cs b/src/Sendspin.SDK/Synchronization/KalmanClockSynchronizer.cs deleted file mode 100644 index 4479105..0000000 --- a/src/Sendspin.SDK/Synchronization/KalmanClockSynchronizer.cs +++ /dev/null @@ -1,557 +0,0 @@ -using Microsoft.Extensions.Logging; - -namespace Sendspin.SDK.Synchronization; - -/// -/// High-precision clock synchronizer using a 2D Kalman filter. -/// Tracks both clock offset and drift rate for accurate audio synchronization. -/// -/// The Kalman filter state vector is [offset, drift]: -/// - offset: difference between server and client clocks (server_time = client_time + offset) -/// - drift: rate of change of offset (microseconds per second) -/// -/// This approach handles network jitter by statistically filtering measurements -/// while also tracking and compensating for clock drift over time. -/// -public sealed class KalmanClockSynchronizer : IClockSynchronizer -{ - private readonly ILogger? _logger; - private readonly object _lock = new(); - - // Kalman filter state - private double _offset; // Estimated offset in microseconds - private double _drift; // Estimated drift in microseconds per second - private double _offsetVariance; // Uncertainty in offset estimate - private double _driftVariance; // Uncertainty in drift estimate - private double _covariance; // Cross-covariance between offset and drift - - // Timing - private long _lastUpdateTime; // Last measurement time in microseconds - private int _measurementCount; - - // Configuration - private readonly double _processNoiseOffset; // How much offset can change per second - private readonly double _processNoiseDrift; // How much drift rate can change per second - private readonly double _measurementNoise; // Expected measurement noise (RTT variance) - private long _staticDelayMicroseconds; // User-configurable playback delay - - // Adaptive forgetting configuration (from time-filter reference) - private readonly double _forgetVarianceFactor; // forgetFactor^2 - covariance scaling factor - private readonly double _adaptiveCutoff; // Threshold multiplier for triggering forgetting - private readonly int _minSamplesForForgetting; // Don't adapt until this many samples collected - private int _adaptiveForgettingTriggerCount; // Diagnostic counter - - // Convergence tracking - private const int MinMeasurementsForConvergence = 5; - private const int MinMeasurementsForPlayback = 2; // Quick start: 2 measurements like JS/CLI players - private const double MaxOffsetUncertaintyForConvergence = 1000.0; // 1ms uncertainty threshold - - // Drift reliability threshold - don't apply drift until uncertainty is below this - // At 50 μs/s uncertainty, drift compensation won't cause more than ~50μs error per second - private const double MaxDriftUncertaintyForReliable = 50.0; // μs/s - - // Tracking for drift reliability transition (for diagnostics) - private bool _driftReliableLogged; - - /// - /// Current estimated clock offset in microseconds. - /// server_time = client_time + Offset - /// - public double Offset - { - get { lock (_lock) return _offset; } - } - - /// - /// Current estimated clock drift in microseconds per second. - /// Positive means server clock is running faster than client. - /// - public double Drift - { - get { lock (_lock) return _drift; } - } - - /// - /// Uncertainty (standard deviation) of the offset estimate in microseconds. - /// - public double OffsetUncertainty - { - get { lock (_lock) return Math.Sqrt(_offsetVariance); } - } - - /// - /// Number of measurements processed. - /// - public int MeasurementCount - { - get { lock (_lock) return _measurementCount; } - } - - /// - /// Whether the synchronizer has converged to a stable estimate. - /// Requires 5+ measurements and low offset uncertainty. - /// - public bool IsConverged - { - get - { - lock (_lock) - { - return _measurementCount >= MinMeasurementsForConvergence - && Math.Sqrt(_offsetVariance) < MaxOffsetUncertaintyForConvergence; - } - } - } - - /// - /// Whether the synchronizer has enough measurements for playback (at least 2). - /// Unlike , this doesn't require statistical convergence. - /// The sync correction system handles any estimation errors during initial playback. - /// - /// - /// This matches the JS/CLI player behavior which starts after 2 measurements (~300-500ms) - /// rather than waiting for full Kalman filter convergence (5+ measurements, ~1-5 seconds). - /// - public bool HasMinimalSync - { - get - { - lock (_lock) - { - return _measurementCount >= MinMeasurementsForPlayback; - } - } - } - - /// - /// Whether the drift estimate is reliable enough to use for time conversions. - /// Drift estimation requires longer time periods to be accurate, so we don't - /// apply drift compensation until the filter is confident in the estimate. - /// - public bool IsDriftReliable - { - get - { - lock (_lock) - { - return _measurementCount >= MinMeasurementsForConvergence - && Math.Sqrt(_driftVariance) < MaxDriftUncertaintyForReliable; - } - } - } - - /// - /// Creates a new Kalman clock synchronizer with default parameters. - /// - /// Optional logger for diagnostics. - /// Process noise for offset (default: 100 μs²/s). - /// Process noise for drift (default: 1 μs²/s²). - /// Measurement noise variance (default: 10000 μs², ~3ms std dev). - /// Adaptive forgetting factor (default: 1.0 = disabled, 1.001 = recommended). - /// When prediction error exceeds the threshold, covariance is scaled by forgetFactor² to - /// "forget" old measurements faster and recover from network disruptions. - /// Threshold multiplier for triggering adaptive forgetting (default: 0.75). - /// Forgetting triggers when prediction error exceeds adaptiveCutoff × sqrt(predicted variance). - /// Minimum measurements before adaptive forgetting activates (default: 100). - /// Prevents forgetting during initial convergence phase. - public KalmanClockSynchronizer( - ILogger? logger = null, - double processNoiseOffset = 100.0, - double processNoiseDrift = 1.0, - double measurementNoise = 10000.0, - double forgetFactor = 1.0, - double adaptiveCutoff = 0.75, - int minSamplesForForgetting = 100) - { - _logger = logger; - _processNoiseOffset = processNoiseOffset; - _processNoiseDrift = processNoiseDrift; - _measurementNoise = measurementNoise; - _forgetVarianceFactor = forgetFactor * forgetFactor; // Square for covariance scaling - _adaptiveCutoff = adaptiveCutoff; - _minSamplesForForgetting = minSamplesForForgetting; - - Reset(); - } - - /// - /// Resets the synchronizer to initial state. - /// - public void Reset() - { - lock (_lock) - { - _offset = 0; - _drift = 0; - _offsetVariance = 1e12; // Start with very high uncertainty (1 second) - _driftVariance = 1e6; // 1000 μs/s uncertainty - _covariance = 0; - _lastUpdateTime = 0; - _measurementCount = 0; - _driftReliableLogged = false; - _adaptiveForgettingTriggerCount = 0; - } - - _logger?.LogDebug("Clock synchronizer reset"); - } - - /// - /// Processes a complete time exchange measurement. - /// - /// Client transmit time (T1) in microseconds. - /// Server receive time (T2) in microseconds. - /// Server transmit time (T3) in microseconds. - /// Client receive time (T4) in microseconds. - public void ProcessMeasurement(long t1, long t2, long t3, long t4) - { - // Calculate offset using NTP formula - // offset = ((T2 - T1) + (T3 - T4)) / 2 - double measuredOffset = ((t2 - t1) + (t3 - t4)) / 2.0; - - // Round-trip time for quality assessment - // RTT = (T4 - T1) - (T3 - T2) - double rtt = (t4 - t1) - (t3 - t2); - - // Server processing time - double serverProcessing = t3 - t2; - - lock (_lock) - { - // First measurement: initialize state - if (_measurementCount == 0) - { - _offset = measuredOffset; - _lastUpdateTime = t4; - _measurementCount = 1; - - _logger?.LogDebug( - "Initial time sync: offset={Offset:F0}μs, RTT={RTT:F0}μs", - measuredOffset, rtt); - return; - } - - // Calculate time delta since last update (in seconds) - double dt = (t4 - _lastUpdateTime) / 1_000_000.0; - if (dt <= 0) - { - _logger?.LogWarning("Non-positive time delta: {Dt}s, skipping measurement", dt); - return; - } - - // ═══════════════════════════════════════════════════════════════════ - // KALMAN FILTER PREDICT STEP - // ═══════════════════════════════════════════════════════════════════ - // State transition: offset += drift * dt - // The drift rate stays the same (random walk model) - double predictedOffset = _offset + _drift * dt; - double predictedDrift = _drift; - - // Predict covariance: P = F * P * F' + Q - // F = [1, dt; 0, 1] (state transition matrix) - // Q = [q_offset, 0; 0, q_drift] * dt (process noise) - double p00 = _offsetVariance + 2 * _covariance * dt + _driftVariance * dt * dt - + _processNoiseOffset * dt; - double p01 = _covariance + _driftVariance * dt; - double p11 = _driftVariance + _processNoiseDrift * dt; - - // ═══════════════════════════════════════════════════════════════════ - // ADAPTIVE FORGETTING (from time-filter reference implementation) - // ═══════════════════════════════════════════════════════════════════ - // When prediction error is large (network disruption, clock adjustment), - // scale covariance to "forget" old measurements faster and recover quickly. - if (_measurementCount >= _minSamplesForForgetting && _forgetVarianceFactor > 1.0) - { - double predictionError = Math.Abs(measuredOffset - predictedOffset); - double threshold = _adaptiveCutoff * Math.Sqrt(p00); - - if (predictionError > threshold) - { - p00 *= _forgetVarianceFactor; - p01 *= _forgetVarianceFactor; - p11 *= _forgetVarianceFactor; - _adaptiveForgettingTriggerCount++; - - _logger?.LogWarning( - "⚡ Adaptive forgetting triggered (#{Count}): prediction error {Error:F0}μs > " + - "threshold {Threshold:F0}μs. Scaling covariance by {Factor:F6} for faster recovery.", - _adaptiveForgettingTriggerCount, - predictionError, - threshold, - _forgetVarianceFactor); - } - } - - // ═══════════════════════════════════════════════════════════════════ - // KALMAN FILTER UPDATE STEP - // ═══════════════════════════════════════════════════════════════════ - // We only measure the offset directly, H = [1, 0] - - // Adaptive measurement noise based on RTT - // Higher RTT = more uncertain measurement - double adaptiveMeasurementNoise = _measurementNoise + rtt * rtt / 4.0; - - // Innovation (measurement residual) - double innovation = measuredOffset - predictedOffset; - - // Innovation covariance: S = H * P * H' + R = P[0,0] + R - double innovationVariance = p00 + adaptiveMeasurementNoise; - - // Kalman gain: K = P * H' / S = [P[0,0], P[0,1]]' / S - double k0 = p00 / innovationVariance; // Gain for offset - double k1 = p01 / innovationVariance; // Gain for drift - - // Update state estimate - _offset = predictedOffset + k0 * innovation; - _drift = predictedDrift + k1 * innovation; - - // Update covariance: P = (I - K * H) * P - _offsetVariance = (1 - k0) * p00; - _covariance = (1 - k0) * p01; - _driftVariance = p11 - k1 * p01; - - // Ensure covariance stays positive definite - if (_offsetVariance < 0) _offsetVariance = 1; - if (_driftVariance < 0) _driftVariance = 0.01; - - _lastUpdateTime = t4; - _measurementCount++; - - // Log progress - if (_measurementCount <= 10 || _measurementCount % 10 == 0) - { - _logger?.LogDebug( - "Time sync #{Count}: offset={Offset:F0}μs (±{Uncertainty:F0}), " + - "drift={Drift:F2}μs/s (±{DriftUncertainty:F1}), RTT={RTT:F0}μs", - _measurementCount, - _offset, - Math.Sqrt(_offsetVariance), - _drift, - Math.Sqrt(_driftVariance), - rtt); - } - - // Log when drift becomes reliable for the first time - bool driftNowReliable = _measurementCount >= MinMeasurementsForConvergence - && Math.Sqrt(_driftVariance) < MaxDriftUncertaintyForReliable; - if (driftNowReliable && !_driftReliableLogged) - { - _driftReliableLogged = true; - _logger?.LogInformation( - "[ClockSync] Drift reliable: drift={Drift:F2}μs/s (±{Uncertainty:F1}μs/s), " + - "offset={Offset:F0}μs, measurements={Count}. " + - "Future timestamps will include drift compensation.", - _drift, - Math.Sqrt(_driftVariance), - _offset, - _measurementCount); - } - } - } - - /// - /// Converts a client timestamp to server time. - /// - /// Client time in microseconds. - /// Estimated server time in microseconds. - public long ClientToServerTime(long clientTime) - { - lock (_lock) - { - // Account for drift since last update, but only if drift estimate is reliable - // Early drift estimates are essentially noise and can make timing worse - if (_lastUpdateTime > 0) - { - double elapsedSeconds = (clientTime - _lastUpdateTime) / 1_000_000.0; - - // Only apply drift compensation when we're confident in the estimate - bool driftReliable = _measurementCount >= MinMeasurementsForConvergence - && Math.Sqrt(_driftVariance) < MaxDriftUncertaintyForReliable; - - double currentOffset = driftReliable - ? _offset + _drift * elapsedSeconds - : _offset; - - return clientTime + (long)currentOffset; - } - return clientTime + (long)_offset; - } - } - - /// - /// Converts a server timestamp to client time. - /// - /// Server time in microseconds. - /// Estimated client time in microseconds. - /// - /// - /// Includes static delay: positive delay means play LATER (adds to client time). - /// This allows manual tuning to sync with other players. - /// - /// - /// Applies drift compensation when drift estimate is reliable, mirroring - /// the behavior of ClientToServerTime. This is critical for accurate audio - /// timestamp conversion during long playback sessions. - /// - /// - public long ServerToClientTime(long serverTime) - { - lock (_lock) - { - // Account for drift since last update (mirrors ClientToServerTime behavior) - // This is critical for audio sync - without drift compensation, timestamps - // drift apart during playback causing progressive sync errors - if (_lastUpdateTime > 0) - { - // We need elapsed time since last update to extrapolate drift. - // Use the approximate client time to calculate elapsed seconds. - long approxClientTime = serverTime - (long)_offset; - double elapsedSeconds = (approxClientTime - _lastUpdateTime) / 1_000_000.0; - - // Only apply drift when we're confident in the estimate - bool driftReliable = _measurementCount >= MinMeasurementsForConvergence - && Math.Sqrt(_driftVariance) < MaxDriftUncertaintyForReliable; - - double currentOffset = driftReliable - ? _offset + _drift * elapsedSeconds - : _offset; - - // Static delay is added (positive = play later, per user preference) - return serverTime - (long)currentOffset + _staticDelayMicroseconds; - } - - return serverTime - (long)_offset + _staticDelayMicroseconds; - } - } - - /// - /// Gets or sets the static delay in milliseconds. - /// Positive values delay playback (play later), negative values advance it (play earlier). - /// - public double StaticDelayMs - { - get => _staticDelayMicroseconds / 1000.0; - set => _staticDelayMicroseconds = (long)(value * 1000); - } - - /// - /// Gets the current synchronization status for diagnostics. - /// - public ClockSyncStatus GetStatus() - { - lock (_lock) - { - return new ClockSyncStatus - { - OffsetMicroseconds = _offset, - DriftMicrosecondsPerSecond = _drift, - OffsetUncertaintyMicroseconds = Math.Sqrt(_offsetVariance), - DriftUncertaintyMicrosecondsPerSecond = Math.Sqrt(_driftVariance), - MeasurementCount = _measurementCount, - IsConverged = IsConverged, - IsDriftReliable = IsDriftReliable, - AdaptiveForgettingTriggerCount = _adaptiveForgettingTriggerCount - }; - } - } -} - -/// -/// Interface for clock synchronization implementations. -/// -public interface IClockSynchronizer -{ - /// - /// Processes a time sync measurement using the NTP 4-timestamp method. - /// - void ProcessMeasurement(long t1, long t2, long t3, long t4); - - /// - /// Converts client time to server time. - /// - long ClientToServerTime(long clientTime); - - /// - /// Converts server time to client time. - /// - long ServerToClientTime(long serverTime); - - /// - /// Whether the synchronizer has converged to a stable estimate. - /// Requires 5+ measurements and low offset uncertainty. - /// - bool IsConverged { get; } - - /// - /// Whether the synchronizer has enough measurements for playback (at least 2). - /// Unlike , this doesn't require statistical convergence. - /// - bool HasMinimalSync { get; } - - /// - /// Resets the synchronizer state. - /// - void Reset(); - - /// - /// Gets the current sync status. - /// - ClockSyncStatus GetStatus(); - - /// - /// Gets or sets the static delay in milliseconds. - /// Positive values delay playback (play later), negative values advance it (play earlier). - /// - double StaticDelayMs { get; set; } -} - -/// -/// Status information about clock synchronization. -/// -public record ClockSyncStatus -{ - /// - /// Estimated offset: server_time = client_time + offset. - /// - public double OffsetMicroseconds { get; init; } - - /// - /// Estimated drift rate in microseconds per second. - /// - public double DriftMicrosecondsPerSecond { get; init; } - - /// - /// Uncertainty (standard deviation) of offset in microseconds. - /// - public double OffsetUncertaintyMicroseconds { get; init; } - - /// - /// Uncertainty (standard deviation) of drift in microseconds per second. - /// - public double DriftUncertaintyMicrosecondsPerSecond { get; init; } - - /// - /// Number of measurements processed. - /// - public int MeasurementCount { get; init; } - - /// - /// Whether synchronization has converged. - /// - public bool IsConverged { get; init; } - - /// - /// Whether drift estimate is reliable enough for compensation. - /// - public bool IsDriftReliable { get; init; } - - /// - /// Number of times adaptive forgetting was triggered due to large prediction errors. - /// This indicates recovery from network disruptions or clock adjustments. - /// - public int AdaptiveForgettingTriggerCount { get; init; } - - /// - /// Offset in milliseconds for display. - /// - public double OffsetMilliseconds => OffsetMicroseconds / 1000.0; -} diff --git a/src/Sendspin.SDK/Synchronization/MonotonicTimer.cs b/src/Sendspin.SDK/Synchronization/MonotonicTimer.cs deleted file mode 100644 index a780cc2..0000000 --- a/src/Sendspin.SDK/Synchronization/MonotonicTimer.cs +++ /dev/null @@ -1,215 +0,0 @@ -// -// Licensed under the MIT License. See LICENSE file in the project root. -// - -using Microsoft.Extensions.Logging; - -namespace Sendspin.SDK.Synchronization; - -/// -/// Wraps a timer to enforce monotonicity and filter out erratic jumps. -/// Designed for VM environments where wall clock can be unreliable. -/// -/// -/// -/// In virtual machines, the underlying timer (Stopwatch/QueryPerformanceCounter) -/// can exhibit erratic behavior due to hypervisor scheduling: -/// - Forward jumps: Timer suddenly advances by hundreds of milliseconds -/// - Backward jumps: Timer returns lower values than previous calls -/// -/// -/// This wrapper filters these anomalies while preserving the ability to detect -/// real DAC clock drift. Real drift accumulates slowly (~50ppm = 3ms/minute), -/// so clamping per-callback deltas to 50ms doesn't hide actual sync issues. -/// -/// -public sealed class MonotonicTimer : IHighPrecisionTimer -{ - private readonly IHighPrecisionTimer _inner; - private readonly ILogger? _logger; - - private long _lastRawTime; - private long _lastReturnedTime; - private bool _initialized; - - // Telemetry counters for diagnosing timer behavior - private long _totalCalls; - private long _backwardJumpCount; - private long _forwardJumpCount; - private long _totalBackwardJumpMicroseconds; - private long _totalForwardJumpMicroseconds; - private long _maxBackwardJumpMicroseconds; - private long _maxForwardJumpMicroseconds; - - /// - /// Gets the total number of GetCurrentTimeMicroseconds calls. - /// - public long TotalCalls => _totalCalls; - - /// - /// Gets the number of backward timer jumps that were filtered. - /// - public long BackwardJumpCount => _backwardJumpCount; - - /// - /// Gets the number of forward timer jumps that were clamped. - /// - public long ForwardJumpCount => _forwardJumpCount; - - /// - /// Gets the total microseconds of backward jumps that were absorbed. - /// - public long TotalBackwardJumpMicroseconds => _totalBackwardJumpMicroseconds; - - /// - /// Gets the total microseconds of forward jumps that were clamped (amount over threshold). - /// - public long TotalForwardJumpClampedMicroseconds => _totalForwardJumpMicroseconds; - - /// - /// Gets the maximum backward jump observed in microseconds. - /// - public long MaxBackwardJumpMicroseconds => _maxBackwardJumpMicroseconds; - - /// - /// Gets the maximum forward jump observed in microseconds. - /// - public long MaxForwardJumpMicroseconds => _maxForwardJumpMicroseconds; - - /// - /// Maximum allowed time advance per call in microseconds. - /// - /// - /// Audio callbacks are typically 10-20ms with ±5ms jitter. - /// 50ms allows for worst-case scheduling while filtering VM timer jumps. - /// - public long MaxDeltaMicroseconds { get; set; } = 50_000; // 50ms - - /// - /// Initializes a new instance of the class. - /// - /// The underlying timer to wrap. If null, uses HighPrecisionTimer.Shared. - /// Optional logger for diagnostic output. - public MonotonicTimer(IHighPrecisionTimer? inner = null, ILogger? logger = null) - { - _inner = inner ?? HighPrecisionTimer.Shared; - _logger = logger; - } - - /// - public long GetCurrentTimeMicroseconds() - { - _totalCalls++; - var rawTime = _inner.GetCurrentTimeMicroseconds(); - - if (!_initialized) - { - _lastRawTime = rawTime; - _lastReturnedTime = rawTime; - _initialized = true; - return rawTime; - } - - var rawDelta = rawTime - _lastRawTime; - _lastRawTime = rawTime; - - // Handle backward jump (timer went backwards) - if (rawDelta < 0) - { - var absJump = -rawDelta; - _backwardJumpCount++; - _totalBackwardJumpMicroseconds += absJump; - if (absJump > _maxBackwardJumpMicroseconds) - { - _maxBackwardJumpMicroseconds = absJump; - } - - _logger?.LogDebug( - "Timer went backward by {DeltaMs:F2}ms, holding at last value (total backward jumps: {Count})", - absJump / 1000.0, - _backwardJumpCount); - // Return last value (time doesn't go backward) - return _lastReturnedTime; - } - - // Handle forward jump (timer jumped ahead) - if (rawDelta > MaxDeltaMicroseconds) - { - var excessMicroseconds = rawDelta - MaxDeltaMicroseconds; - _forwardJumpCount++; - _totalForwardJumpMicroseconds += excessMicroseconds; - if (rawDelta > _maxForwardJumpMicroseconds) - { - _maxForwardJumpMicroseconds = rawDelta; - } - - _logger?.LogDebug( - "Timer jumped forward by {DeltaMs:F2}ms, clamping to {MaxMs}ms (total forward jumps: {Count})", - rawDelta / 1000.0, - MaxDeltaMicroseconds / 1000.0, - _forwardJumpCount); - rawDelta = MaxDeltaMicroseconds; - } - - _lastReturnedTime += rawDelta; - return _lastReturnedTime; - } - - /// - public long GetElapsedMicroseconds(long fromTimeMicroseconds) - { - return GetCurrentTimeMicroseconds() - fromTimeMicroseconds; - } - - /// - /// Resets the timer state. Call when playback restarts. - /// - /// If true, also resets the telemetry counters. - /// - /// This resets the internal state so the next call to GetCurrentTimeMicroseconds - /// will re-initialize from the underlying timer. Use this when starting a new - /// playback session to avoid carrying over stale state. - /// - public void Reset(bool resetTelemetry = false) - { - _initialized = false; - _lastRawTime = 0; - _lastReturnedTime = 0; - - if (resetTelemetry) - { - _totalCalls = 0; - _backwardJumpCount = 0; - _forwardJumpCount = 0; - _totalBackwardJumpMicroseconds = 0; - _totalForwardJumpMicroseconds = 0; - _maxBackwardJumpMicroseconds = 0; - _maxForwardJumpMicroseconds = 0; - } - } - - /// - /// Gets a formatted summary of the timer's filtering activity. - /// Useful for diagnostics and "Stats for Nerds" displays. - /// - /// A string summarizing timer jump filtering stats. - public string GetStatsSummary() - { - if (_totalCalls == 0) - { - return "No timer calls yet"; - } - - var backwardRate = _totalCalls > 0 ? (double)_backwardJumpCount / _totalCalls * 100 : 0; - var forwardRate = _totalCalls > 0 ? (double)_forwardJumpCount / _totalCalls * 100 : 0; - - if (_backwardJumpCount == 0 && _forwardJumpCount == 0) - { - return $"No timer jumps filtered ({_totalCalls:N0} calls)"; - } - - return $"Backward: {_backwardJumpCount:N0} ({backwardRate:F2}%, max {_maxBackwardJumpMicroseconds / 1000.0:F1}ms), " + - $"Forward: {_forwardJumpCount:N0} ({forwardRate:F2}%, max {_maxForwardJumpMicroseconds / 1000.0:F1}ms), " + - $"Total calls: {_totalCalls:N0}"; - } -} diff --git a/src/Sendspin.SDK/update-namespaces.ps1 b/src/Sendspin.SDK/update-namespaces.ps1 deleted file mode 100644 index 63332f6..0000000 --- a/src/Sendspin.SDK/update-namespaces.ps1 +++ /dev/null @@ -1,8 +0,0 @@ -$files = Get-ChildItem -Path . -Recurse -Filter "*.cs" -foreach ($file in $files) { - $content = Get-Content $file.FullName -Raw - $updated = $content -replace 'SendSpinClient\.Core', 'SendSpin.SDK' - Set-Content $file.FullName $updated -NoNewline - Write-Host "Updated: $($file.Name)" -} -Write-Host "Done!" diff --git a/src/SendspinClient.Services/Audio/WasapiAudioPlayer.cs b/src/SendspinClient.Services/Audio/WasapiAudioPlayer.cs index 9c88840..6a729ef 100644 --- a/src/SendspinClient.Services/Audio/WasapiAudioPlayer.cs +++ b/src/SendspinClient.Services/Audio/WasapiAudioPlayer.cs @@ -287,7 +287,7 @@ public void SetSampleSource(IAudioSampleSource source) // Create correction options for drop/insert only (no resampling tier) // Set resampling threshold equal to deadband so it jumps straight to drop/insert var dropInsertOptions = _buffer.SyncOptions.Clone(); - dropInsertOptions.ResamplingThresholdMicroseconds = dropInsertOptions.EntryDeadbandMicroseconds; + dropInsertOptions.ResamplingThresholdMicroseconds = dropInsertOptions.DeadbandMicroseconds; // Create correction provider for external sync correction var calculator = new SyncCorrectionCalculator( diff --git a/src/SendspinClient.Services/SendspinClient.Services.csproj b/src/SendspinClient.Services/SendspinClient.Services.csproj index bc9fd6b..91e529c 100644 --- a/src/SendspinClient.Services/SendspinClient.Services.csproj +++ b/src/SendspinClient.Services/SendspinClient.Services.csproj @@ -17,7 +17,7 @@ - + diff --git a/src/SendspinClient/SendspinClient.csproj b/src/SendspinClient/SendspinClient.csproj index 5027866..5eae05b 100644 --- a/src/SendspinClient/SendspinClient.csproj +++ b/src/SendspinClient/SendspinClient.csproj @@ -53,7 +53,7 @@ - + diff --git a/tests/Sendspin.SDK.Tests/Connection/SimpleWebSocketServerTests.cs b/tests/Sendspin.SDK.Tests/Connection/SimpleWebSocketServerTests.cs deleted file mode 100644 index 6b9b971..0000000 --- a/tests/Sendspin.SDK.Tests/Connection/SimpleWebSocketServerTests.cs +++ /dev/null @@ -1,117 +0,0 @@ -using System.Net.WebSockets; -using Sendspin.SDK.Connection; - -namespace Sendspin.SDK.Tests.Connection; - -public class SimpleWebSocketServerTests : IAsyncDisposable -{ - private readonly SimpleWebSocketServer _server = new(); - - [Fact] - public async Task Server_AcceptsWebSocketConnection() - { - _server.Start(0); // port 0 = OS assigns a random available port - - var connected = new TaskCompletionSource(); - _server.ClientConnected += (s, c) => connected.TrySetResult(c); - - using var client = new ClientWebSocket(); - await client.ConnectAsync(new Uri($"ws://127.0.0.1:{_server.Port}/sendspin"), CancellationToken.None); - - var serverConn = await connected.Task.WaitAsync(TimeSpan.FromSeconds(5)); - - Assert.NotNull(serverConn); - Assert.Equal("/sendspin", serverConn.Path); - Assert.Equal(WebSocketState.Open, client.State); - - await serverConn.DisposeAsync(); - } - - [Fact] - public async Task Server_SendsAndReceivesTextMessages() - { - _server.Start(0); - - var connected = new TaskCompletionSource(); - _server.ClientConnected += (s, c) => connected.TrySetResult(c); - - using var client = new ClientWebSocket(); - await client.ConnectAsync(new Uri($"ws://127.0.0.1:{_server.Port}/test"), CancellationToken.None); - - var serverConn = await connected.Task.WaitAsync(TimeSpan.FromSeconds(5)); - - // Client sends, server receives - var received = new TaskCompletionSource(); - serverConn.OnMessage = msg => received.TrySetResult(msg); - - var msgBytes = System.Text.Encoding.UTF8.GetBytes("hello from client"); - await client.SendAsync(msgBytes, WebSocketMessageType.Text, true, CancellationToken.None); - - var text = await received.Task.WaitAsync(TimeSpan.FromSeconds(5)); - Assert.Equal("hello from client", text); - - // Server sends, client receives - await serverConn.SendAsync("hello from server"); - - var buffer = new byte[1024]; - var result = await client.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); - var response = System.Text.Encoding.UTF8.GetString(buffer, 0, result.Count); - Assert.Equal("hello from server", response); - - await serverConn.DisposeAsync(); - } - - [Fact] - public async Task Server_SendsAndReceivesBinaryMessages() - { - _server.Start(0); - - var connected = new TaskCompletionSource(); - _server.ClientConnected += (s, c) => connected.TrySetResult(c); - - using var client = new ClientWebSocket(); - await client.ConnectAsync(new Uri($"ws://127.0.0.1:{_server.Port}/test"), CancellationToken.None); - - var serverConn = await connected.Task.WaitAsync(TimeSpan.FromSeconds(5)); - - var received = new TaskCompletionSource(); - serverConn.OnBinary = data => received.TrySetResult(data); - - var payload = new byte[] { 0x04, 0x00, 0x01, 0x02, 0x03 }; - await client.SendAsync(payload, WebSocketMessageType.Binary, true, CancellationToken.None); - - var data = await received.Task.WaitAsync(TimeSpan.FromSeconds(5)); - Assert.Equal(payload, data); - - await serverConn.DisposeAsync(); - } - - [Fact] - public async Task Server_RaisesOnClose_WhenClientDisconnects() - { - _server.Start(0); - - var connected = new TaskCompletionSource(); - _server.ClientConnected += (s, c) => connected.TrySetResult(c); - - using var client = new ClientWebSocket(); - await client.ConnectAsync(new Uri($"ws://127.0.0.1:{_server.Port}/test"), CancellationToken.None); - - var serverConn = await connected.Task.WaitAsync(TimeSpan.FromSeconds(5)); - - var closed = new TaskCompletionSource(); - serverConn.OnClose = () => closed.TrySetResult(true); - - await client.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None); - - var wasClosed = await closed.Task.WaitAsync(TimeSpan.FromSeconds(5)); - Assert.True(wasClosed); - - await serverConn.DisposeAsync(); - } - - public async ValueTask DisposeAsync() - { - await _server.DisposeAsync(); - } -} diff --git a/tests/Sendspin.SDK.Tests/Protocol/MessageSerializerTests.cs b/tests/Sendspin.SDK.Tests/Protocol/MessageSerializerTests.cs deleted file mode 100644 index a7841a4..0000000 --- a/tests/Sendspin.SDK.Tests/Protocol/MessageSerializerTests.cs +++ /dev/null @@ -1,94 +0,0 @@ -using Sendspin.SDK.Protocol; -using Sendspin.SDK.Protocol.Messages; - -namespace Sendspin.SDK.Tests.Protocol; - -public class MessageSerializerTests -{ - [Fact] - public void Serialize_ClientTimeMessage_RoundTrips() - { - var original = new ClientTimeMessage - { - Payload = new ClientTimePayload { ClientTransmitted = 123456789 } - }; - - var json = MessageSerializer.Serialize(original); - var deserialized = MessageSerializer.Deserialize(json); - - Assert.NotNull(deserialized); - Assert.Equal("client/time", deserialized.Type); - Assert.Equal(123456789, deserialized.Payload.ClientTransmitted); - } - - [Fact] - public void Serialize_UsesSnakeCaseNaming() - { - var msg = new ClientTimeMessage - { - Payload = new ClientTimePayload { ClientTransmitted = 100 } - }; - - var json = MessageSerializer.Serialize(msg); - - Assert.Contains("\"client_transmitted\"", json); - Assert.DoesNotContain("\"ClientTransmitted\"", json); - } - - [Fact] - public void Deserialize_ServerHelloMessage_ParsesCorrectly() - { - var json = """ - { - "type": "server/hello", - "payload": { - "server_id": "test-server", - "name": "Test Server", - "version": 1, - "active_roles": ["player@v1"], - "connection_reason": "discovery" - } - } - """; - - var msg = MessageSerializer.Deserialize(json) as ServerHelloMessage; - - Assert.NotNull(msg); - Assert.Equal("test-server", msg.ServerId); - Assert.Equal("Test Server", msg.Name); - Assert.Equal(1, msg.Version); - Assert.Single(msg.ActiveRoles); - Assert.Equal("discovery", msg.ConnectionReason); - } - - [Fact] - public void Deserialize_UnknownType_ReturnsNull() - { - var json = """{"type": "unknown/type", "payload": {}}"""; - var result = MessageSerializer.Deserialize(json); - Assert.Null(result); - } - - [Fact] - public void Deserialize_AllServerMessageTypes_Succeeds() - { - var testCases = new Dictionary - { - ["server/hello"] = typeof(ServerHelloMessage), - ["server/time"] = typeof(ServerTimeMessage), - ["stream/start"] = typeof(StreamStartMessage), - ["stream/end"] = typeof(StreamEndMessage), - ["stream/clear"] = typeof(StreamClearMessage), - ["group/update"] = typeof(GroupUpdateMessage), - ["server/command"] = typeof(ServerCommandMessage), - }; - - foreach (var (type, expectedType) in testCases) - { - var json = $$"""{ "type": "{{type}}", "payload": {} }"""; - var msg = MessageSerializer.Deserialize(json); - Assert.NotNull(msg); - Assert.IsType(expectedType, msg); - } - } -} diff --git a/tests/Sendspin.SDK.Tests/Protocol/OptionalJsonConverterTests.cs b/tests/Sendspin.SDK.Tests/Protocol/OptionalJsonConverterTests.cs deleted file mode 100644 index 013e4bb..0000000 --- a/tests/Sendspin.SDK.Tests/Protocol/OptionalJsonConverterTests.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Sendspin.SDK.Protocol; -using Sendspin.SDK.Protocol.Messages; - -namespace Sendspin.SDK.Tests.Protocol; - -public class OptionalJsonConverterTests -{ - [Fact] - public void Optional_AbsentField_DeserializesToAbsent() - { - // JSON with no "progress" field — should be Absent - var json = """ - { - "type": "server/state", - "payload": { - "metadata": { - "title": "Test Song", - "artist": "Test Artist" - } - } - } - """; - - var msg = MessageSerializer.Deserialize(json); - Assert.NotNull(msg); - Assert.NotNull(msg.Payload.Metadata); - Assert.True(msg.Payload.Metadata.Progress.IsAbsent); - } - - [Fact] - public void Optional_ExplicitNull_DeserializesToPresentNull() - { - // JSON with "progress": null — means track ended - var json = """ - { - "type": "server/state", - "payload": { - "metadata": { - "title": "Test Song", - "artist": "Test Artist", - "progress": null - } - } - } - """; - - var msg = MessageSerializer.Deserialize(json); - Assert.NotNull(msg); - Assert.NotNull(msg.Payload.Metadata); - Assert.True(msg.Payload.Metadata.Progress.IsPresent); - Assert.Null(msg.Payload.Metadata.Progress.Value); - } -} diff --git a/tests/Sendspin.SDK.Tests/Sendspin.SDK.Tests.csproj b/tests/Sendspin.SDK.Tests/Sendspin.SDK.Tests.csproj deleted file mode 100644 index aa69ab0..0000000 --- a/tests/Sendspin.SDK.Tests/Sendspin.SDK.Tests.csproj +++ /dev/null @@ -1,25 +0,0 @@ - - - - net10.0 - enable - enable - false - - - - - - - - - - - - - - - - - - \ No newline at end of file