Skip to content

Building a Minimal Player

Chris edited this page Jan 29, 2026 · 2 revisions

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.

Overview

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

Project Setup

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 NAudio

Step 1: Implement IAudioPlayer

The 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

Step 2: Wire Up the Audio Pipeline

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);

Step 3: Connect Client with Pipeline

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();

Complete Program.cs

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

Understanding Sync Correction

The minimal player above works, but for best sync accuracy, you should implement sync correction. The SDK reports sync error; your app applies correction.

How Sync Error Works

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.

Tiered Correction Strategy

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

Adding Sync Correction

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.

Platform Alternatives

Linux (OpenAL)

// Use OpenTK.Audio.OpenAL
public class OpenALPlayer : IAudioPlayer
{
    private ALDevice _device;
    private ALContext _context;
    // ... similar structure, different audio backend
}

Cross-Platform (SDL2)

// 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
    }
}

Next Steps


Previous: Getting Started