-
Notifications
You must be signed in to change notification settings - Fork 2
Building a Minimal Player
This tutorial walks you through building a complete Sendspin player with synchronized audio playback. By the end, you'll have a working player that syncs with other Sendspin clients.
To play audio, you need to implement one interface: IAudioPlayer. The SDK handles everything else:
| SDK Provides | You Implement |
|---|---|
| Protocol handling |
IAudioPlayer (audio output) |
| Clock synchronization | Audio backend (WASAPI, ALSA, etc.) |
| Audio decoding (Opus, FLAC, PCM) | Sync correction application |
| Timestamped buffering | |
| Sync error calculation |
dotnet new console -n MinimalPlayer
cd MinimalPlayer
# SDK
dotnet add package Sendspin.SDK
# Logging
dotnet add package Microsoft.Extensions.Logging.Console
# Audio backend (Windows - NAudio)
dotnet add package NAudioThe IAudioPlayer interface is the bridge between the SDK and your platform's audio system. Here's a minimal implementation using NAudio for Windows:
using NAudio.Wave;
using Sendspin.SDK.Audio;
using Sendspin.SDK.Models;
public sealed class SimpleWasapiPlayer : IAudioPlayer
{
private WasapiOut? _wasapiOut;
private WaveFormat? _waveFormat;
private IAudioSampleSource? _sampleSource;
private SampleSourceProvider? _provider;
public AudioPlayerState State { get; private set; } = AudioPlayerState.Uninitialized;
public float Volume { get; set; } = 1.0f;
public bool IsMuted { get; set; }
public int OutputLatencyMs { get; private set; }
public event EventHandler<AudioPlayerState>? StateChanged;
public event EventHandler<AudioPlayerError>? ErrorOccurred;
public Task InitializeAsync(AudioFormat format, CancellationToken ct = default)
{
// Create NAudio wave format (SDK always outputs 32-bit float)
_waveFormat = WaveFormat.CreateIeeeFloatWaveFormat(format.SampleRate, format.Channels);
// Create WASAPI output (shared mode, 50ms latency)
_wasapiOut = new WasapiOut(
NAudio.CoreAudioApi.AudioClientShareMode.Shared,
latency: 50);
OutputLatencyMs = 50;
SetState(AudioPlayerState.Stopped);
return Task.CompletedTask;
}
public void SetSampleSource(IAudioSampleSource source)
{
_sampleSource = source;
_provider = new SampleSourceProvider(source, _waveFormat!);
_wasapiOut?.Init(_provider);
}
public void Play()
{
_wasapiOut?.Play();
SetState(AudioPlayerState.Playing);
}
public void Pause()
{
_wasapiOut?.Pause();
SetState(AudioPlayerState.Paused);
}
public void Stop()
{
_wasapiOut?.Stop();
SetState(AudioPlayerState.Stopped);
}
public Task SwitchDeviceAsync(string? deviceId, CancellationToken ct = default)
{
// For simplicity, this minimal example doesn't support device switching
return Task.CompletedTask;
}
public async ValueTask DisposeAsync()
{
_wasapiOut?.Stop();
_wasapiOut?.Dispose();
}
private void SetState(AudioPlayerState state)
{
State = state;
StateChanged?.Invoke(this, state);
}
}
/// <summary>
/// Bridges IAudioSampleSource to NAudio's ISampleProvider.
/// </summary>
internal sealed class SampleSourceProvider : ISampleProvider
{
private readonly IAudioSampleSource _source;
public WaveFormat WaveFormat { get; }
public SampleSourceProvider(IAudioSampleSource source, WaveFormat format)
{
_source = source;
WaveFormat = format;
}
public int Read(float[] buffer, int offset, int count)
{
return _source.Read(buffer, offset, count);
}
}Key Points:
- The SDK always provides 32-bit float samples to
IAudioSampleSource- You must report
OutputLatencyMs- used for sync error compensation- The NAudio callback runs on a background thread
The AudioPipeline orchestrates the flow from network to speakers. It needs factories for creating components:
using Microsoft.Extensions.Logging;
using Sendspin.SDK.Audio;
using Sendspin.SDK.Client;
using Sendspin.SDK.Connection;
using Sendspin.SDK.Discovery;
using Sendspin.SDK.Synchronization;
// Logging
using var loggerFactory = LoggerFactory.Create(b =>
{
b.AddConsole();
b.SetMinimumLevel(LogLevel.Information);
});
var logger = loggerFactory.CreateLogger<Program>();
// Core components
var clockSync = new KalmanClockSynchronizer(
loggerFactory.CreateLogger<KalmanClockSynchronizer>());
var decoderFactory = new AudioDecoderFactory();
// Factory functions for pipeline
Func<AudioFormat, IClockSynchronizer, ITimedAudioBuffer> bufferFactory =
(format, sync) =>
{
var buffer = new TimedAudioBuffer(
format,
sync,
bufferCapacityMs: 8000, // 8 seconds max buffer
syncOptions: SyncCorrectionOptions.Default,
logger: loggerFactory.CreateLogger<TimedAudioBuffer>());
buffer.TargetBufferMilliseconds = 250; // Start playing after 250ms buffered
return buffer;
};
Func<IAudioPlayer> playerFactory =
() => new SimpleWasapiPlayer();
Func<ITimedAudioBuffer, Func<long>, IAudioSampleSource> sourceFactory =
(buffer, getCurrentTime) => new BufferedAudioSampleSource(buffer, getCurrentTime);
// Create the pipeline
var pipeline = new AudioPipeline(
loggerFactory.CreateLogger<AudioPipeline>(),
decoderFactory,
clockSync,
bufferFactory,
playerFactory,
sourceFactory,
precisionTimer: null, // Use default high-precision timer
waitForConvergence: true, // Wait for clock sync before playing
convergenceTimeoutMs: 5000); // Max 5 seconds wait
pipeline.StateChanged += (sender, state) =>
logger.LogInformation("Pipeline state: {State}", state);
pipeline.ErrorOccurred += (sender, error) =>
logger.LogError("Pipeline error: {Message}", error.Message);Now connect the SendspinClientService with the pipeline:
// Discovery
var discovery = new MdnsServerDiscovery(
loggerFactory.CreateLogger<MdnsServerDiscovery>());
await discovery.StartAsync();
logger.LogInformation("Scanning for servers...");
await Task.Delay(5000);
var servers = discovery.Servers.ToList();
if (servers.Count == 0)
{
logger.LogError("No servers found!");
return;
}
// Connection
var connection = new SendspinConnection(
loggerFactory.CreateLogger<SendspinConnection>());
var capabilities = new ClientCapabilities
{
ClientName = "Minimal Player",
ProductName = "Tutorial Player",
SoftwareVersion = "1.0.0"
};
// Create client WITH the pipeline
var client = new SendspinClientService(
loggerFactory.CreateLogger<SendspinClientService>(),
connection,
clockSync,
capabilities,
audioPipeline: pipeline); // <-- This enables automatic audio handling!
// Events
client.GroupStateChanged += (sender, group) =>
{
var meta = group.Metadata;
logger.LogInformation("Now playing: {Title} by {Artist}",
meta?.Title ?? "Unknown",
meta?.Artist ?? "Unknown");
};
// Connect
var serverUri = servers.First().GetWebSocketUri();
await client.ConnectAsync(serverUri);
logger.LogInformation("Connected! Audio should be playing...");
// Keep running
Console.WriteLine("Press Enter to quit...");
Console.ReadLine();
await client.DisconnectAsync();
await discovery.StopAsync();Here's the complete, copy-pasteable code:
using Microsoft.Extensions.Logging;
using NAudio.Wave;
using Sendspin.SDK.Audio;
using Sendspin.SDK.Client;
using Sendspin.SDK.Connection;
using Sendspin.SDK.Discovery;
using Sendspin.SDK.Models;
using Sendspin.SDK.Synchronization;
// === LOGGING ===
using var loggerFactory = LoggerFactory.Create(b =>
{
b.AddConsole();
b.SetMinimumLevel(LogLevel.Information);
});
var logger = loggerFactory.CreateLogger<Program>();
// === CORE SDK COMPONENTS ===
var clockSync = new KalmanClockSynchronizer(
loggerFactory.CreateLogger<KalmanClockSynchronizer>());
var decoderFactory = new AudioDecoderFactory();
// === PIPELINE FACTORIES ===
Func<AudioFormat, IClockSynchronizer, ITimedAudioBuffer> bufferFactory =
(format, sync) =>
{
var buffer = new TimedAudioBuffer(
format, sync, bufferCapacityMs: 8000,
syncOptions: SyncCorrectionOptions.Default,
logger: loggerFactory.CreateLogger<TimedAudioBuffer>());
buffer.TargetBufferMilliseconds = 250;
return buffer;
};
Func<IAudioPlayer> playerFactory = () => new SimpleWasapiPlayer();
Func<ITimedAudioBuffer, Func<long>, IAudioSampleSource> sourceFactory =
(buffer, getTime) => new BufferedAudioSampleSource(buffer, getTime);
// === CREATE PIPELINE ===
var pipeline = new AudioPipeline(
loggerFactory.CreateLogger<AudioPipeline>(),
decoderFactory,
clockSync,
bufferFactory,
playerFactory,
sourceFactory,
precisionTimer: null,
waitForConvergence: true,
convergenceTimeoutMs: 5000);
pipeline.StateChanged += (s, state) =>
logger.LogInformation("Pipeline: {State}", state);
// === DISCOVER SERVERS ===
var discovery = new MdnsServerDiscovery(
loggerFactory.CreateLogger<MdnsServerDiscovery>());
await discovery.StartAsync();
logger.LogInformation("Scanning for servers (5 seconds)...");
await Task.Delay(5000);
var servers = discovery.Servers.ToList();
if (servers.Count == 0)
{
logger.LogError("No Sendspin servers found!");
return;
}
// === CREATE CLIENT ===
var connection = new SendspinConnection(
loggerFactory.CreateLogger<SendspinConnection>());
var capabilities = new ClientCapabilities
{
ClientName = "Minimal Player",
ProductName = "Tutorial",
SoftwareVersion = "1.0.0"
};
var client = new SendspinClientService(
loggerFactory.CreateLogger<SendspinClientService>(),
connection,
clockSync,
capabilities,
audioPipeline: pipeline);
client.GroupStateChanged += (s, group) =>
logger.LogInformation("Now playing: {Title} by {Artist}",
group.Metadata?.Title ?? "Unknown",
group.Metadata?.Artist ?? "Unknown");
// === CONNECT ===
var serverUri = servers.First().GetWebSocketUri();
logger.LogInformation("Connecting to {Uri}...", serverUri);
await client.ConnectAsync(serverUri);
logger.LogInformation("Connected! Audio playing...");
Console.WriteLine("Press Enter to quit...");
Console.ReadLine();
await client.DisconnectAsync();
await discovery.StopAsync();
// === AUDIO PLAYER IMPLEMENTATION ===
public sealed class SimpleWasapiPlayer : IAudioPlayer
{
private WasapiOut? _wasapiOut;
private WaveFormat? _waveFormat;
private IAudioSampleSource? _sampleSource;
public AudioPlayerState State { get; private set; } = AudioPlayerState.Uninitialized;
public float Volume { get; set; } = 1.0f;
public bool IsMuted { get; set; }
public int OutputLatencyMs { get; private set; }
public event EventHandler<AudioPlayerState>? StateChanged;
public event EventHandler<AudioPlayerError>? ErrorOccurred;
public Task InitializeAsync(AudioFormat format, CancellationToken ct = default)
{
_waveFormat = WaveFormat.CreateIeeeFloatWaveFormat(format.SampleRate, format.Channels);
_wasapiOut = new WasapiOut(
NAudio.CoreAudioApi.AudioClientShareMode.Shared, latency: 50);
OutputLatencyMs = 50;
SetState(AudioPlayerState.Stopped);
return Task.CompletedTask;
}
public void SetSampleSource(IAudioSampleSource source)
{
_sampleSource = source;
var provider = new SampleSourceProvider(source, _waveFormat!);
_wasapiOut?.Init(provider);
}
public void Play() { _wasapiOut?.Play(); SetState(AudioPlayerState.Playing); }
public void Pause() { _wasapiOut?.Pause(); SetState(AudioPlayerState.Paused); }
public void Stop() { _wasapiOut?.Stop(); SetState(AudioPlayerState.Stopped); }
public Task SwitchDeviceAsync(string? deviceId, CancellationToken ct = default)
=> Task.CompletedTask;
public async ValueTask DisposeAsync()
{
_wasapiOut?.Stop();
_wasapiOut?.Dispose();
}
private void SetState(AudioPlayerState state)
{
State = state;
StateChanged?.Invoke(this, state);
}
}
internal sealed class SampleSourceProvider : ISampleProvider
{
private readonly IAudioSampleSource _source;
public WaveFormat WaveFormat { get; }
public SampleSourceProvider(IAudioSampleSource source, WaveFormat format)
{
_source = source;
WaveFormat = format;
}
public int Read(float[] buffer, int offset, int count)
=> _source.Read(buffer, offset, count);
}The minimal player above works, but for best sync accuracy, you should implement sync correction. The SDK reports sync error; your app applies correction.
Server time: 0ms 100ms 200ms 300ms ...
| | | |
Audio chunks: [100ms] [100ms] [100ms] [100ms]
| | | |
Local time: 50ms 150ms 250ms 350ms (50ms behind!)
If your player runs slightly slow, the sync error grows over time. The SDK tracks this in ITimedAudioBuffer.SyncErrorMicroseconds.
| Sync Error | Method | Description |
|---|---|---|
| < 1ms | None | Too small to matter |
| 1-15ms | Playback rate | Speed up/slow down slightly (imperceptible) |
| 15-500ms | Drop/insert frames | Skip or repeat frames |
| > 500ms | Re-anchor | Clear buffer and restart |
For production players, use SyncCorrectionCalculator:
// In your IAudioPlayer or sample source:
private SyncCorrectionCalculator? _correctionCalculator;
public void InitializeCorrection(int sampleRate, int channels)
{
_correctionCalculator = new SyncCorrectionCalculator(
SyncCorrectionOptions.Default, // Or CliDefaults for more aggressive
sampleRate,
channels);
// React to correction changes
_correctionCalculator.CorrectionChanged += provider =>
{
// Apply playback rate to your resampler
// myResampler.Rate = provider.TargetPlaybackRate;
};
}
// In your audio callback:
public int Read(float[] buffer, int offset, int count)
{
// Read raw samples (no internal correction)
int read = _timedBuffer.ReadRaw(buffer, offset, count, GetCurrentTimeMicroseconds());
// Update correction provider
_correctionCalculator?.UpdateFromSyncError(
_timedBuffer.SyncErrorMicroseconds,
_timedBuffer.SmoothedSyncErrorMicroseconds);
// Notify buffer of any drops/inserts you performed
_timedBuffer.NotifyExternalCorrection(droppedSamples, insertedSamples);
return read;
}For the complete sync correction implementation, see the Windows client's WasapiAudioPlayer.
// Use OpenTK.Audio.OpenAL
public class OpenALPlayer : IAudioPlayer
{
private ALDevice _device;
private ALContext _context;
// ... similar structure, different audio backend
}// Use SDL2-CS
public class SDL2Player : IAudioPlayer
{
public Task InitializeAsync(AudioFormat format, CancellationToken ct)
{
SDL.SDL_Init(SDL.SDL_INIT_AUDIO);
// Set up SDL_AudioSpec with callback
}
}- Architecture - Deep dive into how the SDK components work
- API Reference - Quick reference for all interfaces
- Windows Client Source - Production-quality NAudio implementation
Previous: Getting Started