Phase 21.2: back-pressure to bound the unplayed decoded region

Shared scheduler fill signal (forward water-marks + hard byte cap) pauses
the C# read loop above high-water and, for Opus, stops the demux/decode
feed so WebCodecs queues stay near-empty. Routes through the existing
cancellation discipline; releases the latch on clear/seek.
This commit is contained in:
daniel-c-harvey
2026-06-23 23:16:08 -04:00
parent a2becf45d6
commit 518479e7ae
8 changed files with 436 additions and 8 deletions
@@ -16,6 +16,14 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
// Adaptive chunk sizing
private const int MinBufferSize = 16 * 1024; // 16KB minimum
private const int MaxBufferSize = 64 * 1024; // 64KB maximum
// Phase 21.2a back-pressure poll interval. While the scheduler is over its forward high-water
// mark, the read loop stops calling ReadAsync and polls IsProductionPaused at this cadence
// until the fill drains below low-water. 100 ms is well under the low-water lookahead (seconds),
// so resume is prompt relative to the playhead — no starvation (AC3) — while keeping the poll
// cheap. The poll honors the loop's cancellation token, so a track switch/seek during a pause
// exits through the same drain discipline as a pause during ReadAsync (C6).
private const int BackpressurePollMs = 100;
private int _currentBufferSize = DefaultBufferSize;
private int _consecutiveSlowReads = 0;
@@ -465,6 +473,26 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
}
await ThrottledNotifyStateChanged();
// Phase 21.2a back-pressure (serves BOTH paths). The chunk we just processed
// reported the scheduler's forward fill is over the high-water mark — stop
// reading the socket so the unplayed decoded region stays bounded. Pausing
// ReadAsync lets the kernel TCP window close (we are working WITH transport flow
// control, not against it). Poll until the fill drains below low-water, then
// resume the loop. For WAV this is the whole story (StreamDecoder decodes
// synchronously into the scheduler); the Opus feed additionally self-throttles
// its demux/decode off the SAME signal (21.2b), so its upstream queues stay
// near-empty too. The poll awaits on cancellationToken, so a track switch/seek
// mid-pause throws OCE and unwinds through the existing drain discipline (C6) —
// no separate cancellation path, no stale read racing a reinit.
if (chunkResult.ProductionPaused)
{
while (await _audioInterop.IsProductionPaused(PlayerId))
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Delay(BackpressurePollMs, cancellationToken);
}
}
}
} while (currentBytes > 0);