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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 102 additions & 5 deletions src/Sendspin.SDK/Audio/TimedAudioBuffer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ public double SmoothedSyncErrorMicroseconds
/// </summary>
/// <param name="format">Audio format for samples.</param>
/// <param name="clockSync">Clock synchronizer for timestamp conversion.</param>
/// <param name="bufferCapacityMs">Maximum buffer capacity in milliseconds. Should be large enough to absorb the server's initial burst (typically 30s).</param>
/// <param name="bufferCapacityMs">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.</param>
/// <param name="syncOptions">Optional sync correction options. Uses <see cref="SyncCorrectionOptions.Default"/> if not provided.</param>
/// <param name="logger">Optional logger for diagnostics (uses NullLogger if not provided).</param>
public TimedAudioBuffer(
Expand Down Expand Up @@ -241,14 +241,35 @@ public void Write(ReadOnlySpan<float> samples, long serverTimestamp)
// Convert server timestamp to local playback time
var localPlaybackTime = _clockSync.ServerToClientTime(serverTimestamp);

// Check for overrun - drop oldest if needed
// 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);
_overrunCount++;
_logger.LogWarning(
"[Buffer] Overrun #{Count}: dropped {DroppedMs:F1}ms of audio to make room (buffer full at {CapacityMs}ms)",
_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);
Expand Down Expand Up @@ -315,6 +336,18 @@ public int Read(Span<float> buffer, long currentLocalTime)
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;
Expand Down Expand Up @@ -470,6 +503,18 @@ public int ReadRaw(Span<float> buffer, long currentLocalTime)
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;
Expand Down Expand Up @@ -866,6 +911,58 @@ private int PeekSamplesFromBufferAtOffset(Span<float> destination, int count, in
return peeked;
}

/// <summary>
/// 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.
/// </summary>
/// <param name="currentLocalTime">Current local time in microseconds.</param>
/// <returns>Number of samples skipped.</returns>
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;
}

/// <summary>
/// Drops the oldest samples to make room for new data.
/// Must be called under lock.
Expand Down
4 changes: 3 additions & 1 deletion src/Sendspin.SDK/Client/ClientCapabilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ public sealed class ClientCapabilities
};

/// <summary>
/// Audio buffer capacity in bytes (32MB like reference implementation).
/// 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).
/// </summary>
public int BufferCapacity { get; set; } = 32_000_000;

Expand Down
33 changes: 27 additions & 6 deletions src/SendspinClient/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,24 @@ private void ConfigureServices(IServiceCollection services)

Log.Information("Player volume: {Volume}%, Muted: {Muted}", playerVolume, playerMuted);

// Read buffer capacity early — needed for both ClientCapabilities and AudioPipeline
const int minBufferCapacityMs = 60000; // 60s minimum — OPUS bursts can exceed 40s
var configuredCapacityMs = _configuration!.GetValue<int>("Audio:Buffer:CapacityMs", 120000);
var bufferCapacityMs = Math.Max(configuredCapacityMs, minBufferCapacityMs);

// Derive compressed-byte buffer capacity from our PCM buffer duration.
// The server uses this to limit how much audio it sends ahead.
// Use worst-case bitrate (PCM uncompressed) so no codec can overflow our buffer.
var maxBytesPerSecond = audioFormats
.Select(f => f.Codec == "opus" && f.Bitrate > 0
? f.Bitrate * 1000 / 8 // OPUS: use declared bitrate (kbps → bytes/sec)
: f.SampleRate * f.Channels * Math.Max(f.BitDepth ?? 16, 16) / 8) // PCM/FLAC: raw sample rate
.Max();
var bufferCapacityBytes = (int)((long)bufferCapacityMs * maxBytesPerSecond / 1000);
Log.Information(
"Buffer: {CapacityMs}ms PCM → {CapacityMB:F1}MB advertised to server (worst-case {MaxKbps}kbps)",
bufferCapacityMs, bufferCapacityBytes / 1024.0 / 1024.0, maxBytesPerSecond * 8 / 1000);

services.AddSingleton(new ClientCapabilities
{
ClientId = clientId,
Expand All @@ -219,7 +237,8 @@ private void ConfigureServices(IServiceCollection services)
SoftwareVersion = appVersion,
AudioFormats = audioFormats,
InitialVolume = playerVolume,
InitialMuted = playerMuted
InitialMuted = playerMuted,
BufferCapacity = bufferCapacityBytes
});

// Clock synchronization for multi-room audio sync
Expand Down Expand Up @@ -284,12 +303,14 @@ private void ConfigureServices(IServiceCollection services)
var decoderFactory = sp.GetRequiredService<IAudioDecoderFactory>();
var clockSync = sp.GetRequiredService<IClockSynchronizer>();

// Read buffer configuration
// Capacity must hold the full server burst without overflowing.
// We advertise 32MB BufferCapacity (compressed bytes) — for FLAC with ~4:1
// compression, that decodes to 30+ seconds of audio. Default 30s matches this.
// Buffer capacity (bufferCapacityMs) was computed earlier for ClientCapabilities
var bufferTargetMs = _configuration!.GetValue<double>("Audio:Buffer:TargetMs", 250);
var bufferCapacityMs = _configuration!.GetValue<int>("Audio:Buffer:CapacityMs", 30000);
if (configuredCapacityMs < minBufferCapacityMs)
{
logger.LogWarning(
"Buffer capacity {ConfiguredMs}ms is below minimum {MinMs}ms (stale user config?), using {ActualMs}ms",
configuredCapacityMs, minBufferCapacityMs, bufferCapacityMs);
}

// Read clock sync wait configuration
var waitForConvergence = _configuration!.GetValue<bool>("Audio:ClockSync:WaitForConvergence", true);
Expand Down
2 changes: 1 addition & 1 deletion src/SendspinClient/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"StaticDelayMs": 200,
"Buffer": {
"TargetMs": 250,
"CapacityMs": 30000
"CapacityMs": 120000
},
"ClockSync": {
"ForgetFactor": 1.001,
Expand Down
Loading