Phase 21 Direction B: bound network memory via Range-segmented forward fetch

Replace the open-ended forward GET with sequential bounded bytes=start-end
segments, the next fetched only when the scheduler drains below low-water,
so the browser holds ~one segment regardless of file size. Seek converges
on the same loop. Strip BP-DIAG.
This commit is contained in:
daniel-c-harvey
2026-06-24 13:20:37 -04:00
parent def297e7d9
commit 11faf8888f
6 changed files with 687 additions and 279 deletions
@@ -20,13 +20,24 @@ public class TrackMediaResponse : IDisposable
/// </summary> /// </summary>
public string ContentType { get; } public string ContentType { get; }
/// <summary>
/// The total file length in bytes, parsed from the 206 response's <c>Content-Range:
/// bytes start-end/TOTAL</c> header (Phase 21 Direction B). Null when the server returned
/// 200 (no Content-Range) — callers fall back to <see cref="ContentLength"/> as the total.
/// This is the EOF boundary the segment loop advances its cursor toward, and the full
/// logical length the JS decoder must see (so a bounded segment's small Content-Length
/// never trips the decoder's byte-count completion early).
/// </summary>
public long? TotalLength { get; }
private readonly HttpResponseMessage _response; private readonly HttpResponseMessage _response;
public TrackMediaResponse(Stream stream, long contentLength, string contentType, HttpResponseMessage response) public TrackMediaResponse(Stream stream, long contentLength, string contentType, long? totalLength, HttpResponseMessage response)
{ {
Stream = stream; Stream = stream;
ContentLength = contentLength; ContentLength = contentLength;
ContentType = contentType; ContentType = contentType;
TotalLength = totalLength;
_response = response; _response = response;
} }
@@ -54,6 +65,15 @@ public class TrackMediaClient
/// token aborts the in-flight server connection rather than leaving the server /// token aborts the in-flight server connection rather than leaving the server
/// draining bytes into a dead socket. /// draining bytes into a dead socket.
/// <para> /// <para>
/// <paramref name="byteEnd"/> (Phase 21 Direction B) bounds the request to a single
/// segment: when set, the Range header is <c>bytes={byteOffset}-{byteEnd}</c> (inclusive),
/// so the browser holds at most ~one segment of raw bytes regardless of file size — the
/// network-memory bound this phase exists for. When null the request is open-ended
/// (<c>bytes={byteOffset}-</c>), the pre-Direction-B behaviour. Either way the response's
/// <c>Content-Range</c> total is surfaced via <see cref="TrackMediaResponse.TotalLength"/>
/// so the caller knows the EOF boundary and the full logical length the decoder must see.
/// </para>
/// <para>
/// <paramref name="format"/> selects the delivery rendering (Phase 18): the default /// <paramref name="format"/> selects the delivery rendering (Phase 18): the default
/// <see cref="AudioFormat.Lossless"/> sends no <c>format</c> query param, so existing /// <see cref="AudioFormat.Lossless"/> sends no <c>format</c> query param, so existing
/// callers hit the byte-identical pre-Phase-18 endpoint; <see cref="AudioFormat.Opus"/> /// callers hit the byte-identical pre-Phase-18 endpoint; <see cref="AudioFormat.Opus"/>
@@ -65,12 +85,13 @@ public class TrackMediaClient
public async Task<ApiResult<TrackMediaResponse>> GetTrackMedia( public async Task<ApiResult<TrackMediaResponse>> GetTrackMedia(
string trackId, string trackId,
long byteOffset = 0, long byteOffset = 0,
long? byteEnd = null,
AudioFormat format = AudioFormat.Lossless, AudioFormat format = AudioFormat.Lossless,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
try try
{ {
// Same URL for every seek — only the Range header differs. byteOffset 0 is // Same URL for every fetch — only the Range header differs. byteOffset 0 is
// not special-cased: "bytes=0-" requests the whole file from the start. // not special-cased: "bytes=0-" requests the whole file from the start.
// Lossless omits the format param entirely so the request is byte-identical to // Lossless omits the format param entirely so the request is byte-identical to
// the pre-Phase-18 endpoint; only Opus appends ?format=opus. // the pre-Phase-18 endpoint; only Opus appends ?format=opus.
@@ -78,18 +99,19 @@ public class TrackMediaClient
? $"api/track/{trackId}" ? $"api/track/{trackId}"
: $"api/track/{trackId}?format={format.ToString().ToLowerInvariant()}"; : $"api/track/{trackId}?format={format.ToString().ToLowerInvariant()}";
using var request = new HttpRequestMessage(HttpMethod.Get, uri); using var request = new HttpRequestMessage(HttpMethod.Get, uri);
request.Headers.Range = new RangeHeaderValue(byteOffset, null); // Bounded (byteEnd set) → "bytes=start-end" so the server returns a finite 206
// slice and the browser buffers only that segment; open-ended (byteEnd null) →
// "bytes=start-". The server honours both via File(..., enableRangeProcessing: true),
// which parses the full RFC 7233 range grammar and slices accordingly.
request.Headers.Range = new RangeHeaderValue(byteOffset, byteEnd);
// Stream the response body incrementally instead of buffering it whole (Phase 21.4 fix). // Stream the response body incrementally instead of buffering it whole (Phase 21.4 fix).
// In Blazor WebAssembly the HttpClient is backed by the browser fetch API; without this the // In Blazor WebAssembly the HttpClient is backed by the browser fetch API; without this the
// browser buffers the ENTIRE body before the response stream yields a byte, so the 21.2 // browser buffers the ENTIRE body before the response stream yields a byte. With Direction B
// read-loop pause (StreamingAudioPlayerService) backpressures nothing — the whole payload is // each request is already bounded to one segment, so the body is small regardless — but
// already in memory. Enabling streaming makes ReadAsync pull from a browser ReadableStream // streaming still lets us read it incrementally and is harmless on the SSR server-to-server
// whose backpressure reaches the underlying fetch, so pausing reads genuinely throttles the // path (SocketsHttpHandler ignores the unknown option). Kept for both the initial and the
// network. This is a request-option flag, not a runtime call: on the SSR server-to-server path // seek/refill paths since both share this method.
// the SocketsHttpHandler simply ignores the unknown option, so it is safe unguarded. Applies to
// BOTH the initial stream (byteOffset 0) and the seek/refill Range requests (21.3) — both share
// this method, so both depend on the same backpressure.
request.SetBrowserResponseStreamingEnabled(true); request.SetBrowserResponseStreamingEnabled(true);
// Use HttpCompletionOption.ResponseHeadersRead to get stream immediately // Use HttpCompletionOption.ResponseHeadersRead to get stream immediately
@@ -100,11 +122,15 @@ public class TrackMediaClient
// Default to WAV when the server omits the header — the only format shipping // Default to WAV when the server omits the header — the only format shipping
// today — so the JS factory always receives a usable media type. // today — so the JS factory always receives a usable media type.
var contentType = response.Content.Headers.ContentType?.MediaType ?? "audio/wav"; var contentType = response.Content.Headers.ContentType?.MediaType ?? "audio/wav";
// Content-Range "bytes start-end/TOTAL" carries the full file length on a 206; on a 200
// there is no Content-Range, so TotalLength is null and callers use ContentLength.
var totalLength = response.Content.Headers.ContentRange?.Length;
var stream = await response.Content.ReadAsStreamAsync(cancellationToken); var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
// TrackMediaResponse takes ownership of both stream and response; // TrackMediaResponse takes ownership of both stream and response;
// do NOT dispose response here — the caller disposes via TrackMediaResponse.Dispose(). // do NOT dispose response here — the caller disposes via TrackMediaResponse.Dispose().
return ApiResult<TrackMediaResponse>.CreatePassResult(new TrackMediaResponse(stream, contentLength, contentType, response)); return ApiResult<TrackMediaResponse>.CreatePassResult(
new TrackMediaResponse(stream, contentLength, contentType, totalLength, response));
} }
catch (Exception e) catch (Exception e)
{ {
@@ -18,12 +18,23 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
private const int MaxBufferSize = 64 * 1024; // 64KB maximum private const int MaxBufferSize = 64 * 1024; // 64KB maximum
// Phase 21.2a back-pressure poll interval. While the scheduler is over its forward high-water // 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 // mark, the segment loop stops fetching the next segment and polls IsProductionPaused at this
// until the fill drains below low-water. 100 ms is well under the low-water lookahead (seconds), // cadence until the fill drains below low-water. 100 ms is well under the low-water lookahead
// so resume is prompt relative to the playhead — no starvation (AC3) — while keeping the poll // (seconds), so resume is prompt relative to the playhead — no starvation (AC3) — while keeping
// cheap. The poll honors the loop's cancellation token, so a track switch/seek during a pause // the poll cheap. The poll honors the loop's cancellation token, so a track switch/seek during a
// exits through the same drain discipline as a pause during ReadAsync (C6). // pause exits through the same drain discipline as a pause during ReadAsync (C6).
private const int BackpressurePollMs = 100; private const int BackpressurePollMs = 100;
// Phase 21 Direction B — forward Range-segment size. The forward stream is fetched as a
// sequence of bounded "bytes=cursor-(cursor+SegmentSizeBytes-1)" 206 requests, the next issued
// only when the scheduler drains below low-water. Because each request is bounded and fully
// consumed before the next is issued, the browser fetch holds AT MOST ~one segment of raw bytes
// regardless of file size — this is the network-memory bound the phase exists for (the open-ended
// single GET buffered the whole ~970 MB body in the browser even when reads were paused, the
// 21.4 finding). 4 MB balances request overhead (a 1 GB mix is ~250 segments) against memory:
// at the 30 s high-water mark a fast connection holds well under a segment of unplayed raw bytes,
// so the bound is the segment size, not the decoded window. Tunable; not magic.
private const long SegmentSizeBytes = 4 * 1024 * 1024;
private int _currentBufferSize = DefaultBufferSize; private int _currentBufferSize = DefaultBufferSize;
private int _consecutiveSlowReads = 0; private int _consecutiveSlowReads = 0;
@@ -35,14 +46,6 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
public int BufferedChunks { get; private set; } = 0; public int BufferedChunks { get; private set; } = 0;
public bool IsSeekingBeyondBuffer { get; private set; } = false; public bool IsSeekingBeyondBuffer { get; private set; } = false;
// ───────────────────────────────────────────────────────────────────────────────────────────
// [BP-DIAG] Phase 21.4 back-pressure diagnostic. TEMPORARY — strip once the cause is confirmed
// in Daniel's browser run. Logs every Nth chunk's ProductionPaused flag plus pause-poll
// enter/exit so a grep for "[BP-DIAG]" in the WASM console tells whether the read loop ever sees
// the pause signal and whether the poll actually holds. Throttled by chunk count to avoid flooding.
private const int BpDiagChunkLogEvery = 16;
// ───────────────────────────────────────────────────────────────────────────────────────────
private bool _streamingPlaybackStarted = false; private bool _streamingPlaybackStarted = false;
private CancellationTokenSource? _streamingCancellation; private CancellationTokenSource? _streamingCancellation;
private Task? _activeStreamingTask; private Task? _activeStreamingTask;
@@ -203,23 +206,29 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
// seek-beyond-buffer re-fetch reuses the same artifact. // seek-beyond-buffer re-fetch reuses the same artifact.
_currentFormat = await ResolveStreamFormatAsync(track.EntryKey, loadCts.Token); _currentFormat = await ResolveStreamFormatAsync(track.EntryKey, loadCts.Token);
// Pass the streaming token to the HTTP layer so a navigation/track switch // Direction B: fetch the FIRST bounded segment to learn the total file length and the
// aborts the server connection instead of leaving it draining bytes. // content type. The 206 Content-Range carries the total; the segment loop advances its
var mediaResult = await _trackMediaClient.GetTrackMedia( // cursor toward it. The decoder is initialized with the TOTAL length (not the segment
// length) so a bounded segment's small Content-Length never trips its byte-count
// completion early — segment boundaries are invisible to the decoder, which sees one
// continuous in-order byte stream. Passing the streaming token aborts the server
// connection on a navigation/track switch instead of leaving it draining bytes.
var firstSegment = await _trackMediaClient.GetTrackMedia(
track.EntryKey, track.EntryKey,
byteOffset: 0, byteOffset: 0,
byteEnd: SegmentSizeBytes - 1,
format: _currentFormat, format: _currentFormat,
cancellationToken: loadCts.Token); cancellationToken: loadCts.Token);
if (!mediaResult.Success) if (!firstSegment.Success)
{ {
var technicalError = mediaResult.GetMessage(); var technicalError = firstSegment.GetMessage();
_logger.LogError("Failed to get track media for {TrackId}: {Error}", _logger.LogError("Failed to get track media for {TrackId}: {Error}",
track.EntryKey, technicalError); track.EntryKey, technicalError);
ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(technicalError); ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(technicalError);
return; return;
} }
if (mediaResult.Value == null) if (firstSegment.Value == null)
{ {
const string technicalError = "No audio returned from server"; const string technicalError = "No audio returned from server";
_logger.LogError("No audio data returned for track {TrackId}", track.EntryKey); _logger.LogError("No audio data returned for track {TrackId}", track.EntryKey);
@@ -227,13 +236,22 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
return; return;
} }
using var audio = mediaResult.Value; // Ownership of the first segment transfers to the segment loop, which disposes it (and
// every subsequent segment). No `using` here — a double dispose is avoided and the socket
// is released the moment the loop finishes consuming the segment.
var audio = firstSegment.Value;
// Initialize streaming mode with content length and media type (drives // The total logical length the decoder must see. On a 206 the Content-Range carries it;
// JS format-decoder selection). // a 200 (server ignored Range / file ≤ one segment) has no Content-Range, so fall back to
var streamingResult = await _audioInterop.InitializeStreaming(PlayerId, audio.ContentLength, audio.ContentType); // the body's own Content-Length — that body IS the whole file in that case.
var totalLength = audio.TotalLength ?? audio.ContentLength;
// Initialize streaming mode with the TOTAL length and media type (drives JS
// format-decoder selection). See above: total, not segment, length.
var streamingResult = await _audioInterop.InitializeStreaming(PlayerId, totalLength, audio.ContentType);
if (!streamingResult.Success) if (!streamingResult.Success)
{ {
audio.Dispose();
var technicalError = $"Failed to initialize streaming: {streamingResult.Error}"; var technicalError = $"Failed to initialize streaming: {streamingResult.Error}";
_logger.LogError("Streaming initialization failed for track {TrackId}: {Error}", _logger.LogError("Streaming initialization failed for track {TrackId}: {Error}",
track.EntryKey, technicalError); track.EntryKey, technicalError);
@@ -241,7 +259,10 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
return; return;
} }
_activeStreamingTask = StreamAudioWithEarlyPlayback(audio, loadCts.Token); // Forward segmentation from byte 0. The first segment is already in hand; the loop pumps
// it, then fetches subsequent bounded segments gated on the scheduler fill signal.
_activeStreamingTask = RunSegmentedStreamAsync(
track.EntryKey, audio, cursor: 0, totalLength, seekPosition: null, loadCts.Token);
await _activeStreamingTask; await _activeStreamingTask;
} }
catch (OperationCanceledException) when (loadCts.IsCancellationRequested) catch (OperationCanceledException) when (loadCts.IsCancellationRequested)
@@ -385,176 +406,208 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
return profile; return profile;
} }
private async Task StreamAudioWithEarlyPlayback(TrackMediaResponse audio, CancellationToken cancellationToken) /// <summary>
/// Phase 21 Direction B — the single segmented forward read loop, shared by the initial load and
/// the seek/refill path (the convergence C1/C5 require: one cursor, one fetch mechanism, no forked
/// path). It pumps the FIRST segment (already fetched by the caller), then fetches subsequent
/// bounded <c>bytes=cursor-(cursor+SegmentSizeBytes-1)</c> 206 segments — each only AFTER the
/// scheduler drains below low-water — until the cursor reaches <paramref name="totalLength"/>.
/// Because each segment is bounded and fully consumed before the next is requested, the browser
/// holds at most ~one segment of raw bytes (the network-memory bound), while the decoder sees one
/// continuous in-order byte stream across segment boundaries (the demuxer/decoder buffer partial
/// frames/pages across the boundary exactly as for arbitrary chunks today — no per-segment reinit).
/// </summary>
/// <param name="firstSegment">The already-fetched first segment (byte <paramref name="cursor"/>).
/// Owned by this method, which disposes it; subsequent segments are fetched and disposed inline.</param>
/// <param name="cursor">File-absolute byte offset the first segment starts at (0 for a fresh load,
/// the resolved seek offset for a refill).</param>
/// <param name="totalLength">Total file length in bytes — the EOF boundary the cursor advances
/// toward. The decoder is initialized/reinitialized against this, not the per-segment length.</param>
/// <param name="seekPosition">Non-null for a seek/refill: the decoder is reinitialized for the
/// header-less Range continuation at this time before the first segment's bytes are fed (WAV
/// retains its header, Opus re-applies the cached setup + lead-trim). Null for a forward load from
/// byte 0, where the first segment carries the header and no reinit is needed.</param>
private async Task RunSegmentedStreamAsync(
string trackId,
TrackMediaResponse firstSegment,
long cursor,
long totalLength,
double? seekPosition,
CancellationToken cancellationToken)
{ {
byte[]? buffer = null; byte[]? buffer = null;
var segment = firstSegment;
try try
{ {
long totalBytesRead = 0; // Seek/refill: reinitialize the active decoder for the header-less continuation ONCE,
buffer = ArrayPool<byte>.Shared.Rent(MaxBufferSize); // Rent larger buffer to accommodate adaptive sizing // before any continuation bytes are fed. Forward-from-zero (seekPosition null) skips this
int currentBytes; // — its first segment carries the real header the decoder parses. Done here, inside the
var readTimer = System.Diagnostics.Stopwatch.StartNew(); // single loop, so seek and forward share the same fetch+pump mechanism (no forked path).
var bpDiagChunkIndex = 0; // [BP-DIAG] per-stream chunk counter for throttled logging if (seekPosition is { } resumeAt)
do
{ {
readTimer.Restart(); // The decoder byte-counts the header-less continuation against the bytes REMAINING
currentBytes = await audio.Stream.ReadAsync(buffer, 0, _currentBufferSize, cancellationToken); // from the range start to EOF (total cursor), not the absolute total — that is what
readTimer.Stop(); // reinitializeForRangeContinuation expects (StreamDecoder.remainingByteLength). The
// loop's own cursor still targets the absolute totalLength for EOF.
// Adapt buffer size based on read performance var remainingBytes = Math.Max(0, totalLength - cursor);
AdaptBufferSize(currentBytes, readTimer.ElapsedMilliseconds); var reinitResult = await _audioInterop.ReinitializeFromOffset(PlayerId, remainingBytes, resumeAt);
if (!reinitResult.Success)
if (currentBytes > 0)
{ {
totalBytesRead += currentBytes; throw new Exception($"Failed to reinitialize for offset streaming: {reinitResult.Error}");
// Always slice to the exact number of bytes read. The pooled buffer
// is rented at MaxBufferSize and may carry stale bytes past
// currentBytes from a prior rental — handing the full array to JS
// interop would serialise that garbage into the audio stream.
var actualBuffer = buffer.AsSpan(0, currentBytes).ToArray();
// Process chunk for streaming
var chunkResult = await _audioInterop.ProcessStreamingChunk(PlayerId, actualBuffer);
if (!chunkResult.Success)
{
var error = $"Failed to process streaming chunk: {chunkResult.Error}";
_logger.LogWarning("Chunk processing failed: {Error}", error);
throw new Exception(error);
}
// Update streaming state
CanStartStreaming = chunkResult.CanStartStreaming;
HeaderParsed = chunkResult.HeaderParsed;
BufferedChunks = chunkResult.BufferCount;
// [BP-DIAG] Phase 21.4 — throttled per-chunk view of the back-pressure signal as
// the C# loop sees it. If ProductionPaused never logs True while bytes keep
// flowing, the break is upstream (JS latch / lookahead math); if it logs True but
// the transfer still races to 100%, the break is the transport (browser buffered
// the whole body, SetBrowserResponseStreamingEnabled not in effect). TEMPORARY.
if (bpDiagChunkIndex % BpDiagChunkLogEvery == 0)
{
_logger.LogInformation(
"[BP-DIAG] chunk #{Chunk} bytesRead={Bytes} totalRead={Total} bufferCount={BufCount} canStart={CanStart} productionPaused={Paused} isPaused={IsPaused}",
bpDiagChunkIndex, currentBytes, totalBytesRead, chunkResult.BufferCount,
chunkResult.CanStartStreaming, chunkResult.ProductionPaused, IsPaused);
}
bpDiagChunkIndex++;
// Set duration from WAV header when available (only set once)
if (chunkResult.Duration.HasValue && Duration == null)
{
Duration = chunkResult.Duration.Value;
_logger.LogInformation("Duration set from WAV header: {Duration:F2} seconds", Duration);
// Feed the same once-only duration to the play session so it can compute the
// completion fraction at close. Safe before/after session open — SetDuration
// is a no-op when no session is open and idempotent otherwise.
_playTracker?.SetDuration(chunkResult.Duration.Value);
}
// Start playback as soon as we can
if (!_streamingPlaybackStarted && CanStartStreaming)
{
var playbackResult = await _audioInterop.StartStreamingPlayback(PlayerId);
if (playbackResult.Success)
{
_streamingPlaybackStarted = true;
IsPlaying = true;
IsPaused = false;
IsLoaded = true; // Track is loaded and ready to play (even if still downloading)
ErrorMessage = null;
// Open the play session exactly once per load, at the moment playback truly
// begins (§2.1). The _sessionOpened guard keeps the SeekBeyondBuffer re-stream
// — which re-enters this transition with _streamingPlaybackStarted reset —
// from opening a second session for the same play. Duration may already be
// known from a prior chunk, so re-feed it after opening.
if (!_sessionOpened && _currentTrackId is { } trackKey)
{
_sessionOpened = true;
_playTracker?.OnPlaybackStarted(trackKey);
if (Duration is { } d)
_playTracker?.SetDuration(d);
}
await NotifyStateChanged(); // Immediate notification for critical state change
}
else
{
var technicalError = $"Failed to start streaming playback: {playbackResult.Error}";
_logger.LogError("Failed to start playback: {Error}", technicalError);
ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(technicalError);
}
}
// Update progress
if (audio.ContentLength > 0)
{
LoadProgress = Math.Min(1.0, (double)totalBytesRead / audio.ContentLength);
}
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)
{
// [BP-DIAG] Phase 21.4 — the read loop is ENTERING the pause-poll: reads have
// stopped, the socket should now stall and the transfer should plateau. If you
// see this line but the network transfer still completes, the transport is
// buffered (streaming flag not in effect). TEMPORARY.
_logger.LogInformation(
"[BP-DIAG] ENTER pause-poll at chunk #{Chunk} totalRead={Total} isPaused={IsPaused}",
bpDiagChunkIndex, totalBytesRead, IsPaused);
var bpDiagPollCount = 0;
// UC5: while the user is paused, the playhead is frozen so forward lookahead
// never shrinks and the poll would spin indefinitely. Wait here until playback
// resumes (IsPaused clears) OR the fill drains on its own. Cancellation is
// unchanged: a track switch/seek/stop while paused still throws OCE and unwinds
// through the existing drain discipline (C6) — no weakening of the cancel path.
while (IsPaused || await _audioInterop.IsProductionPaused(PlayerId))
{
cancellationToken.ThrowIfCancellationRequested();
// [BP-DIAG] Phase 21.4 — heartbeat every ~1 s (10 × 100 ms) so a held poll
// is visible without flooding; shows the loop is genuinely parked. TEMPORARY.
if (bpDiagPollCount % 10 == 0)
{
_logger.LogInformation(
"[BP-DIAG] HOLD pause-poll iter={Iter} isPaused={IsPaused}",
bpDiagPollCount, IsPaused);
}
bpDiagPollCount++;
await Task.Delay(BackpressurePollMs, cancellationToken);
}
// [BP-DIAG] Phase 21.4 — the read loop is EXITING the pause-poll and resuming
// ReadAsync: the fill drained below low-water. TEMPORARY.
_logger.LogInformation(
"[BP-DIAG] EXIT pause-poll at chunk #{Chunk} after {Iters} polls",
bpDiagChunkIndex, bpDiagPollCount);
}
} }
} while (currentBytes > 0); }
// Notify the JS decoder that the stream is finished. When the server omits buffer = ArrayPool<byte>.Shared.Rent(MaxBufferSize); // larger rental to fit adaptive sizing
// Content-Length the StreamDecoder cannot determine completion via byte counting var readTimer = System.Diagnostics.Stopwatch.StartNew();
// alone; this explicit signal ensures the tail-decoding path (streamComplete=true)
// fires regardless of whether Content-Length was present. // Segment loop. Each iteration fully consumes one bounded 206 body, advancing the cursor by
// the bytes received. The next segment is fetched only when the scheduler is below
// high-water (the inter-segment gate). EOF is the cursor reaching totalLength, or a short
// segment (server returned fewer bytes than requested — the final slice).
while (true)
{
long segmentBytesRead = 0;
int currentBytes;
do
{
readTimer.Restart();
currentBytes = await segment.Stream.ReadAsync(buffer, 0, _currentBufferSize, cancellationToken);
readTimer.Stop();
AdaptBufferSize(currentBytes, readTimer.ElapsedMilliseconds);
if (currentBytes > 0)
{
segmentBytesRead += currentBytes;
// Slice to the exact bytes read: the pooled buffer is rented at MaxBufferSize
// and may carry stale bytes past currentBytes from a prior rental — handing the
// full array to JS would serialise that garbage into the audio stream.
var actualBuffer = buffer.AsSpan(0, currentBytes).ToArray();
var chunkResult = await _audioInterop.ProcessStreamingChunk(PlayerId, actualBuffer);
if (!chunkResult.Success)
{
var error = $"Failed to process streaming chunk: {chunkResult.Error}";
_logger.LogWarning("Chunk processing failed: {Error}", error);
throw new Exception(error);
}
CanStartStreaming = chunkResult.CanStartStreaming;
HeaderParsed = chunkResult.HeaderParsed;
BufferedChunks = chunkResult.BufferCount;
// Set duration from header when available (only set once)
if (chunkResult.Duration.HasValue && Duration == null)
{
Duration = chunkResult.Duration.Value;
_logger.LogInformation("Duration set from header: {Duration:F2} seconds", Duration);
// Feed the once-only duration to the play session for the completion
// fraction. No-op when no session is open; idempotent otherwise.
_playTracker?.SetDuration(chunkResult.Duration.Value);
}
// Start playback as soon as we can — at the min-buffer threshold, exactly as
// before (C2: first audio is not gated on the segment boundary; the first
// segment alone clears the threshold).
if (!_streamingPlaybackStarted && CanStartStreaming)
{
var playbackResult = await _audioInterop.StartStreamingPlayback(PlayerId);
if (playbackResult.Success)
{
_streamingPlaybackStarted = true;
IsPlaying = true;
IsPaused = false;
IsLoaded = true; // loaded and ready, even while still downloading
ErrorMessage = null;
// Open the play session exactly once per load, at the moment playback
// truly begins (§2.1). The _sessionOpened guard keeps a seek/refill
// re-stream — which re-enters this transition with
// _streamingPlaybackStarted reset — from opening a second session for
// the same play. Duration may already be known, so re-feed it.
if (!_sessionOpened && _currentTrackId is { } trackKey)
{
_sessionOpened = true;
_playTracker?.OnPlaybackStarted(trackKey);
if (Duration is { } d)
_playTracker?.SetDuration(d);
}
await NotifyStateChanged(); // immediate — critical state change
}
else
{
var technicalError = $"Failed to start streaming playback: {playbackResult.Error}";
_logger.LogError("Failed to start playback: {Error}", technicalError);
ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(technicalError);
}
}
// Progress against the total file length (cursor + bytes consumed so far).
if (totalLength > 0)
{
LoadProgress = Math.Min(1.0, (double)(cursor + segmentBytesRead) / totalLength);
}
await ThrottledNotifyStateChanged();
}
} while (currentBytes > 0);
// Segment fully consumed; advance the cursor and release this segment's stream/socket
// before deciding whether to fetch the next. Disposing here keeps exactly one segment's
// raw bytes resident at a time.
cursor += segmentBytesRead;
segment.Dispose();
segment = null!;
// EOF: cursor reached the total, OR the server returned a short final slice (fewer
// bytes than the segment we requested). Either way there is nothing left to fetch.
var reachedTotal = totalLength > 0 && cursor >= totalLength;
var shortSegment = segmentBytesRead < SegmentSizeBytes;
if (reachedTotal || shortSegment)
{
break;
}
// Inter-segment back-pressure gate (Phase 21.2 fill signal, now gating SEGMENT FETCH
// instead of pacing ReadAsync on an open stream). Do not fetch the next segment while
// the scheduler is over high-water; wait until it drains below low-water. Because the
// browser only buffers bounded segments and we hold off requesting the next one, raw
// network memory stays at ~one segment. The poll awaits on cancellationToken, so a
// track switch/seek mid-wait throws OCE and unwinds through the existing drain
// discipline (C6). UC5: a user pause freezes the playhead so the fill never drains —
// hold here until playback resumes (IsPaused clears) OR the fill drains on its own.
while (IsPaused || await _audioInterop.IsProductionPaused(PlayerId))
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Delay(BackpressurePollMs, cancellationToken);
}
// Fetch the next bounded segment. The end offset is clamped implicitly by the server
// (a request past EOF yields the available tail as a short slice, caught above).
var nextEnd = cursor + SegmentSizeBytes - 1;
var nextResult = await _trackMediaClient.GetTrackMedia(
trackId,
byteOffset: cursor,
byteEnd: nextEnd,
format: _currentFormat,
cancellationToken: cancellationToken);
if (!nextResult.Success || nextResult.Value == null)
{
var technicalError = nextResult.GetMessage() ?? "Failed to fetch next stream segment";
_logger.LogError("Failed to fetch segment at offset {Offset} for {TrackId}: {Error}",
cursor, trackId, technicalError);
throw new Exception(technicalError);
}
segment = nextResult.Value;
}
// Notify the JS decoder that the stream is finished. The decoder marks completion by byte
// count against the total it was initialized with; this explicit signal flushes the
// residual tail and covers the (rare) case where the total was unknown.
await _audioInterop.MarkStreamCompleteAsync(PlayerId); await _audioInterop.MarkStreamCompleteAsync(PlayerId);
// Mark as fully loaded
LoadProgress = 1.0; LoadProgress = 1.0;
await NotifyStateChanged(); await NotifyStateChanged();
} }
@@ -565,7 +618,7 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
} }
catch (Exception ex) catch (Exception ex)
{ {
StreamingErrorHandler.LogError(_logger, ex, "StreamAudioWithEarlyPlayback"); StreamingErrorHandler.LogError(_logger, ex, "RunSegmentedStreamAsync");
ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(ex.Message); ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(ex.Message);
LoadProgress = 0; LoadProgress = 0;
IsLoaded = false; IsLoaded = false;
@@ -575,6 +628,8 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
} }
finally finally
{ {
// Release the last segment (if a fetch failed mid-loop it may still be held) and the buffer.
segment?.Dispose();
if (buffer != null) if (buffer != null)
{ {
ArrayPool<byte>.Shared.Return(buffer); ArrayPool<byte>.Shared.Return(buffer);
@@ -653,6 +708,10 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
return; return;
} }
// Capture into a non-null local: _currentTrackId is the field a track-switch could clear, but
// this seek operates against the track loaded NOW; the segment loop needs a stable id.
var trackId = _currentTrackId;
IsSeekingBeyondBuffer = true; IsSeekingBeyondBuffer = true;
// Cancel the current streaming loop AND wait for it to fully exit before // Cancel the current streaming loop AND wait for it to fully exit before
@@ -691,19 +750,22 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
CurrentTime = seekPosition; CurrentTime = seekPosition;
await NotifyStateChanged(); await NotifyStateChanged();
// Request new stream from offset. Reuse the format the initial load resolved to (_currentFormat): // Request the FIRST bounded segment from the resolved offset (Direction B — converged with
// an Opus seek must come back as Opus bytes so the cached setup header + page-aligned byteOffset // the forward path). Reuse the format the initial load resolved to (_currentFormat): an
// (resolved by the JS decoder's index-based calculateByteOffset) match the continuation. The // Opus seek must come back as Opus bytes so the cached setup header + page-aligned
// offset itself is computed JS-side from the Opus seek index for Opus, exactly as it is from the // byteOffset (resolved JS-side from the Opus seek index) match the continuation; WAV resolves
// WAV header for lossless — one seam, format-appropriate math (AC9 / §3.4a C). // its offset from the header — one seam, format-appropriate math (AC9 / §3.4a C). The
var mediaResult = await _trackMediaClient.GetTrackMedia( // segment loop then continues forward segmentation from this offset exactly as a fresh load
_currentTrackId, // does from 0 — no forked fetch path (C1/C5).
var firstSegment = await _trackMediaClient.GetTrackMedia(
trackId,
byteOffset, byteOffset,
byteEnd: byteOffset + SegmentSizeBytes - 1,
format: _currentFormat, format: _currentFormat,
cancellationToken: seekCts.Token); cancellationToken: seekCts.Token);
if (!mediaResult.Success || mediaResult.Value == null) if (!firstSegment.Success || firstSegment.Value == null)
{ {
var technicalError = mediaResult.GetMessage() ?? "Failed to load audio from position"; var technicalError = firstSegment.GetMessage() ?? "Failed to load audio from position";
_logger.LogError("Failed to get track media from offset {Offset}: {Error}", byteOffset, technicalError); _logger.LogError("Failed to get track media from offset {Offset}: {Error}", byteOffset, technicalError);
// Guard: a superseded seek must NOT touch shared state. The newer seek owns teardown. // Guard: a superseded seek must NOT touch shared state. The newer seek owns teardown.
if (IsStillActiveSeek()) if (IsStillActiveSeek())
@@ -717,33 +779,24 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
return; return;
} }
using var audio = mediaResult.Value; var audio = firstSegment.Value;
// The absolute EOF boundary the segment loop's cursor targets. On a 206 the Content-Range
// carries the file total; on a 200 (single-segment file) fall back to cursor + body length.
var totalLength = audio.TotalLength ?? (byteOffset + audio.ContentLength);
// Reinitialize JS player for offset streaming // Reset streaming state for the new stream. The decoder reinit for the header-less
var reinitResult = await _audioInterop.ReinitializeFromOffset(PlayerId, audio.ContentLength, seekPosition); // continuation happens INSIDE RunSegmentedStreamAsync (seekPosition non-null), so seek and
if (!reinitResult.Success) // forward share one fetch+pump+reinit mechanism. A reinit failure there throws and lands in
{ // the catch below, which recovers when still the active seek — the same clean-failure path
_logger.LogError("Failed to reinitialize for offset streaming: {Error}", reinitResult.Error); // (AC6) the old explicit reinit branch had, now unified with the fetch-failure path.
// Guard: same single-writer discipline — only recover when we are still the active seek.
if (IsStillActiveSeek())
{
await RecoverFromFailedRefill(seekPosition, "Failed to seek to position");
}
else
{
_logger.LogDebug("Reinit failed on superseded seek to {Position} — newer seek owns state, skipping recovery", seekPosition);
}
return;
}
// Reset streaming state for new stream
_streamingPlaybackStarted = false; _streamingPlaybackStarted = false;
CanStartStreaming = false; CanStartStreaming = false;
HeaderParsed = false; HeaderParsed = false;
BufferedChunks = 0; BufferedChunks = 0;
// Stream audio from offset // Stream from offset via the shared segment loop. Ownership of `audio` transfers to it.
_activeStreamingTask = StreamAudioWithEarlyPlayback(audio, seekCts.Token); _activeStreamingTask = RunSegmentedStreamAsync(
trackId, audio, cursor: byteOffset, totalLength, seekPosition, seekCts.Token);
await _activeStreamingTask; await _activeStreamingTask;
IsSeekingBeyondBuffer = false; IsSeekingBeyondBuffer = false;
+2 -28
View File
@@ -269,19 +269,13 @@ export class AudioPlayer {
const headerParsed = decoder.ready; const headerParsed = decoder.ready;
const canStart = headerParsed && this.scheduler.hasMinimumBuffers(this.minBuffersForPlayback); const canStart = headerParsed && this.scheduler.hasMinimumBuffers(this.minBuffersForPlayback);
// [BP-DIAG] Phase 21.4 — value of productionPaused actually placed on the Opus chunk
// result handed to C#. Confirms the flag is populated on THIS path (not just the WAV
// path). TEMPORARY — strip once confirmed.
const opusPaused = this.scheduler.evaluateProductionPause();
this.bpDiagLogChunkResult('opus', canStart, opusPaused);
return { return {
success: true, success: true,
canStartStreaming: canStart, canStartStreaming: canStart,
headerParsed, headerParsed,
bufferCount: this.scheduler.getBufferCount(), bufferCount: this.scheduler.getBufferCount(),
duration: this.duration, duration: this.duration,
productionPaused: opusPaused productionPaused: this.scheduler.evaluateProductionPause()
}; };
} catch (error) { } catch (error) {
return { success: false, error: (error as Error).message }; return { success: false, error: (error as Error).message };
@@ -320,18 +314,13 @@ export class AudioPlayer {
const canStart = this.streamDecoder.headerParsed && const canStart = this.streamDecoder.headerParsed &&
this.scheduler.hasMinimumBuffers(this.minBuffersForPlayback); this.scheduler.hasMinimumBuffers(this.minBuffersForPlayback);
// [BP-DIAG] Phase 21.4 — value of productionPaused actually placed on the WAV/MP3/FLAC
// chunk result handed to C#. TEMPORARY — strip once confirmed.
const formatPaused = this.scheduler.evaluateProductionPause();
this.bpDiagLogChunkResult('format', canStart, formatPaused);
return { return {
success: true, success: true,
canStartStreaming: canStart, canStartStreaming: canStart,
headerParsed: this.streamDecoder.headerParsed, headerParsed: this.streamDecoder.headerParsed,
bufferCount: this.scheduler.getBufferCount(), bufferCount: this.scheduler.getBufferCount(),
duration: this.duration, duration: this.duration,
productionPaused: formatPaused productionPaused: this.scheduler.evaluateProductionPause()
}; };
} catch (error) { } catch (error) {
return { success: false, error: (error as Error).message }; return { success: false, error: (error as Error).message };
@@ -742,21 +731,6 @@ export class AudioPlayer {
// ==================== Private Methods ==================== // ==================== Private Methods ====================
// ─────────────────────────────────────────────────────────────────────────────────────────
// [BP-DIAG] Phase 21.4 back-pressure diagnostic. TEMPORARY — strip once confirmed in Daniel's
// browser run. Logs the productionPaused flag on the chunk result handed back to C#, throttled
// to ~4 Hz so it does not flood. Grep "[BP-DIAG] chunk-result" in the browser console.
private bpDiagChunkResultLastMs = 0;
private bpDiagLogChunkResult(path: 'opus' | 'format', canStart: boolean, paused: boolean): void {
const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
if (now - this.bpDiagChunkResultLastMs < 250) return;
this.bpDiagChunkResultLastMs = now;
console.log(
`[BP-DIAG] chunk-result path=${path} productionPaused=${paused} canStart=${canStart} ` +
`bufCount=${this.scheduler.getBufferCount()} streamingStarted=${this.streamingStarted} isPlaying=${this.isPlaying}`);
}
// ─────────────────────────────────────────────────────────────────────────────────────────
private resetState(): void { private resetState(): void {
this.isPlaying = false; this.isPlaying = false;
this.isPaused = false; this.isPaused = false;
@@ -107,14 +107,6 @@ export class PlaybackScheduler {
// Mutated by evaluateProductionPause() — named to signal the state-advance on each call. // Mutated by evaluateProductionPause() — named to signal the state-advance on each call.
private productionPaused_: boolean = false; private productionPaused_: boolean = false;
// ─────────────────────────────────────────────────────────────────────────────────────────
// [BP-DIAG] Phase 21.4 back-pressure diagnostic. TEMPORARY — strip once the cause is confirmed
// in Daniel's browser run. Throttles evaluateProductionPause() logging to one line per ~250 ms
// so the console shows the live lookahead / byte-estimate / latch without flooding (the signal
// is evaluated on every chunk + every poll). Grep "[BP-DIAG]" in the browser console.
private bpDiagLastLogMs: number = 0;
// ─────────────────────────────────────────────────────────────────────────────────────────
// Callbacks // Callbacks
public onPlaybackEnded: (() => void) | null = null; public onPlaybackEnded: (() => void) | null = null;
@@ -245,22 +237,6 @@ export class PlaybackScheduler {
this.productionPaused_ = true; this.productionPaused_ = true;
} }
// [BP-DIAG] Phase 21.4 — the single source of truth for the latch decision. If `paused`
// never goes true while bytes keep arriving, the lookahead is not growing as expected:
// inspect `lookahead` vs `high` (should cross 30 during a fast fill) and `bufCount`/`bytes`
// (decode must actually be populating the scheduler). Throttled to ~4 Hz. TEMPORARY — strip
// once the cause is confirmed. (Uses performance.now when present; Date.now fallback.)
const bpNow = (typeof performance !== 'undefined' ? performance.now() : Date.now());
if (bpNow - this.bpDiagLastLogMs >= 250) {
this.bpDiagLastLogMs = bpNow;
console.log(
`[BP-DIAG] evaluateProductionPause paused=${this.productionPaused_} ` +
`lookahead=${lookahead.toFixed(2)}s high=${this.forwardHighWaterSeconds} low=${this.forwardLowWaterSeconds} ` +
`bytes=${(this.getDecodedByteEstimate() / (1024 * 1024)).toFixed(1)}MB cap=${(this.maxDecodedBytes / (1024 * 1024)).toFixed(0)}MB ` +
`overByteCeiling=${overByteCeiling} bufCount=${this.buffers.length} pos=${this.getCurrentPosition().toFixed(2)}s ` +
`decodedEnd=${(this.getTotalDuration() + this.playbackOffset).toFixed(2)}s active=${this.isActive_}`);
}
return this.productionPaused_; return this.productionPaused_;
} }
+283
View File
@@ -0,0 +1,283 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Http.Headers;
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Clients;
using DeepDrftPublic.Client.Services;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.JSInterop;
namespace DeepDrftTests;
/// <summary>
/// Phase 21 Direction B — the segmented forward read loop in <see cref="StreamingAudioPlayerService"/>.
/// Drives a real <c>SelectTrackStreaming</c> against a fake JS runtime and a scripted HTTP handler that
/// serves bounded 206 segments, then asserts the loop's contract:
/// <list type="bullet">
/// <item>forward playback fetches in bounded <c>bytes=start-end</c> segments (the network-memory bound);</item>
/// <item>the cursor advances contiguously across segment boundaries until the file total is reached (EOF);</item>
/// <item>the next segment is NOT fetched while the scheduler reports production paused (the fill gate);</item>
/// <item>a seek converges onto the SAME segment loop — reinit then continued segmentation, no forked path.</item>
/// </list>
/// True network/browser memory behaviour is Daniel's manual re-run; this pins the request sequencing and
/// gating the harness can observe.
/// </summary>
[TestFixture]
public class SegmentedStreamLoopTests
{
private const long SegmentSize = 4 * 1024 * 1024;
// Records every audio Range request and serves a bounded 206 slice. Audio bodies are zero-filled —
// the fake JS decoder does not inspect bytes; it scripts canStart/productionPaused directly.
private sealed class SegmentServer : HttpMessageHandler
{
private readonly long _total;
public List<(long From, long? To)> AudioRanges { get; } = new();
public SegmentServer(long total) => _total = total;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var path = request.RequestUri!.AbsolutePath;
// Waveform profile + sidecar fetches are best-effort side calls — 404 them so the load
// path falls back cleanly and the test stays focused on the audio segment loop.
if (path.EndsWith("/waveform") || path.Contains("/opus/"))
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
}
var rangeItem = request.Headers.Range!.Ranges.First();
var from = rangeItem.From ?? 0;
var to = rangeItem.To ?? (_total - 1);
if (to > _total - 1) to = _total - 1;
lock (AudioRanges) AudioRanges.Add((rangeItem.From ?? 0, rangeItem.To));
var body = new byte[Math.Max(0, to - from + 1)];
var response = new HttpResponseMessage(HttpStatusCode.PartialContent)
{
Content = new ByteArrayContent(body),
};
response.Content.Headers.ContentRange = new ContentRangeHeaderValue(from, to, _total);
response.Content.Headers.ContentType = new MediaTypeHeaderValue("audio/wav");
return Task.FromResult(response);
}
}
private sealed class SingleClientFactory : IHttpClientFactory
{
private readonly HttpMessageHandler _handler;
public SingleClientFactory(HttpMessageHandler handler) => _handler = handler;
public HttpClient CreateClient(string name) =>
new(_handler, disposeHandler: false) { BaseAddress = new Uri("https://content.test/") };
}
/// <summary>
/// Scriptable JS runtime. processStreamingChunk reports canStart=true immediately (so playback
/// starts on the first chunk) and a productionPaused value pulled from a queue the test controls;
/// isProductionPaused (the inter-segment poll) reads a separate queue so a test can hold the gate
/// closed for N polls then release it. Records reinitializeFromOffset / markStreamComplete calls.
/// </summary>
private sealed class FakeJsRuntime : IJSRuntime
{
private readonly Func<bool> _chunkProductionPaused;
private readonly Func<bool> _isProductionPaused;
private readonly long? _seekByteOffset;
public FakeJsRuntime(
Func<bool>? chunkProductionPaused = null,
Func<bool>? isProductionPaused = null,
long? seekByteOffset = null)
{
_chunkProductionPaused = chunkProductionPaused ?? (() => false);
_isProductionPaused = isProductionPaused ?? (() => false);
_seekByteOffset = seekByteOffset;
}
public int ReinitCallCount { get; private set; }
public int MarkCompleteCallCount { get; private set; }
public int IsProductionPausedCallCount { get; private set; }
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args)
{
switch (identifier)
{
case "DeepDrftAudio.isReady":
return Ok<TValue>(true);
case "DeepDrftAudio.canDecodeOggOpus":
return Ok<TValue>(false); // force the lossless path — no sidecar dance
case "DeepDrftAudio.isProductionPaused":
IsProductionPausedCallCount++;
return Ok<TValue>(_isProductionPaused());
case "DeepDrftAudio.processStreamingChunk":
return (ValueTask<TValue>)(object)ValueTask.FromResult(new StreamingResult
{
Success = true,
CanStartStreaming = true,
HeaderParsed = true,
BufferCount = 8,
Duration = 600,
ProductionPaused = _chunkProductionPaused(),
});
case "DeepDrftAudio.seek":
// When a seek offset is scripted, report seek-beyond-buffer so Seek() routes into
// SeekBeyondBuffer → the shared segment loop with a continuation reinit.
return (ValueTask<TValue>)(object)ValueTask.FromResult(_seekByteOffset is { } off
? new SeekResult { Success = true, SeekBeyondBuffer = true, ByteOffset = off }
: new SeekResult { Success = true });
case "DeepDrftAudio.reinitializeFromOffset":
ReinitCallCount++;
return Result<TValue>(true);
case "DeepDrftAudio.markStreamComplete":
MarkCompleteCallCount++;
return (ValueTask<TValue>)(object)ValueTask.FromResult(new StreamingResult { Success = true });
default:
// createPlayer / setOnProgressCallback / setOnEndCallback / setVolume /
// ensureAudioContextReady / initializeStreaming / startStreamingPlayback /
// stop / unload / disposePlayer → generic success.
if (typeof(TValue) == typeof(AudioOperationResult))
return Result<TValue>(true);
return Ok<TValue>(default!);
}
}
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken, object?[]? args)
=> InvokeAsync<TValue>(identifier, args);
private static ValueTask<TValue> Ok<TValue>(object? value) => ValueTask.FromResult((TValue)value!);
private static ValueTask<TValue> Result<TValue>(bool success) =>
ValueTask.FromResult((TValue)(object)new AudioOperationResult { Success = success });
}
private static TrackDto Track() => new() { EntryKey = "mix-1", TrackName = "Long Mix" };
private static StreamingAudioPlayerService BuildPlayer(SegmentServer server, FakeJsRuntime js)
{
var interop = new AudioInteropService(js);
var media = new TrackMediaClient(new SingleClientFactory(server));
return new StreamingAudioPlayerService(interop, media, NullLogger<StreamingAudioPlayerService>.Instance);
}
[Test]
public async Task ForwardPlayback_FetchesBoundedSegments_AdvancingCursorToEof()
{
// 10 MB file → 3 segments (4 MB, 4 MB, 2 MB tail). No back-pressure: drains straight through.
var total = 10L * 1024 * 1024;
var server = new SegmentServer(total);
var js = new FakeJsRuntime();
var player = BuildPlayer(server, js);
await player.SelectTrackStreaming(Track());
Assert.Multiple(() =>
{
Assert.That(server.AudioRanges, Has.Count.EqualTo(3),
"a 10 MB file at a 4 MB segment size is fetched as three bounded segments");
// Contiguous, bounded segments advancing the cursor to EOF.
Assert.That(server.AudioRanges[0], Is.EqualTo((0L, (long?)(SegmentSize - 1))));
Assert.That(server.AudioRanges[1], Is.EqualTo((SegmentSize, (long?)(2 * SegmentSize - 1))));
Assert.That(server.AudioRanges[2], Is.EqualTo((2 * SegmentSize, (long?)(3 * SegmentSize - 1))));
Assert.That(js.MarkCompleteCallCount, Is.EqualTo(1), "stream-complete fires once at true EOF, not per segment");
});
}
[Test]
public async Task ForwardPlayback_DoesNotFetchNextSegment_WhileProductionPaused()
{
var total = 10L * 1024 * 1024;
var server = new SegmentServer(total);
// Chunk results report paused=true (so the loop enters the inter-segment gate), and the poll
// stays paused for the first two checks, then releases — so the next segment is delayed, not
// skipped. The gate must hold the SECOND fetch until the poll clears.
var pollChecks = 0;
var js = new FakeJsRuntime(
chunkProductionPaused: () => true,
isProductionPaused: () => ++pollChecks < 3); // paused for polls 1,2; clear on poll 3
var player = BuildPlayer(server, js);
await player.SelectTrackStreaming(Track());
Assert.Multiple(() =>
{
Assert.That(js.IsProductionPausedCallCount, Is.GreaterThanOrEqualTo(3),
"the inter-segment gate must poll the fill signal while paused, not fetch immediately");
Assert.That(server.AudioRanges, Has.Count.EqualTo(3),
"once the gate clears, segmentation resumes and still reaches EOF — paused delays, never skips");
});
}
[Test]
public async Task SmallFile_FetchedInOneShortSegment_NoSecondFetch()
{
// 2 MB file < one segment: the first bounded request returns a short slice → EOF, no second fetch.
var total = 2L * 1024 * 1024;
var server = new SegmentServer(total);
var js = new FakeJsRuntime();
var player = BuildPlayer(server, js);
await player.SelectTrackStreaming(Track());
Assert.Multiple(() =>
{
Assert.That(server.AudioRanges, Has.Count.EqualTo(1), "a sub-segment file needs exactly one fetch");
Assert.That(server.AudioRanges[0], Is.EqualTo((0L, (long?)(SegmentSize - 1))),
"the request is still the bounded segment shape; the server returns the short available tail");
Assert.That(js.MarkCompleteCallCount, Is.EqualTo(1));
});
}
[Test]
public async Task ForwardLoad_NeverReinitializesDecoder()
{
var total = 20L * 1024 * 1024;
var server = new SegmentServer(total);
var js = new FakeJsRuntime();
var player = BuildPlayer(server, js);
await player.SelectTrackStreaming(Track());
Assert.Multiple(() =>
{
Assert.That(server.AudioRanges, Has.Count.EqualTo(5), "20 MB / 4 MB = five contiguous forward segments");
Assert.That(js.ReinitCallCount, Is.Zero,
"a forward load from byte 0 never reinitializes the decoder — reinit is the seek-only continuation step");
});
}
[Test]
public async Task SeekBeyondBuffer_ReinitsOnceThenSegmentsForwardFromOffset()
{
// 20 MB file. After load, seek to a byte offset 12 MB in: the seek must reinit the decoder once,
// then continue the SAME bounded-segment loop from 12 MB to EOF (no forked fetch path — C1/C5).
var total = 20L * 1024 * 1024;
var seekOffset = 12L * 1024 * 1024;
var server = new SegmentServer(total);
var js = new FakeJsRuntime(seekByteOffset: seekOffset);
var player = BuildPlayer(server, js);
await player.SelectTrackStreaming(Track());
var afterLoad = server.AudioRanges.Count;
await player.Seek(300); // arbitrary time; the scripted seek returns the 12 MB byte offset
// Segments fetched by the seek/refill: everything after the initial-load segments.
var refillRanges = server.AudioRanges.Skip(afterLoad).ToList();
Assert.Multiple(() =>
{
Assert.That(js.ReinitCallCount, Is.EqualTo(1),
"a seek-beyond-buffer reinitializes the decoder exactly once for the header-less continuation");
Assert.That(refillRanges, Has.Count.EqualTo(2),
"from 12 MB to 20 MB at a 4 MB segment is two bounded segments (4 MB + 4 MB tail)");
Assert.That(refillRanges[0], Is.EqualTo((seekOffset, (long?)(seekOffset + SegmentSize - 1))),
"the first refill segment is bounded and starts at the resolved seek offset");
Assert.That(refillRanges[1], Is.EqualTo((seekOffset + SegmentSize, (long?)(seekOffset + 2 * SegmentSize - 1))),
"segmentation continues forward from the seek offset — the same loop, the same bounded shape");
});
}
}
@@ -0,0 +1,96 @@
using System.Net;
using System.Net.Http.Headers;
using DeepDrftModels.Enums;
using DeepDrftPublic.Client.Clients;
namespace DeepDrftTests;
/// <summary>
/// Pins the Phase 21 Direction B bounded-Range request shape on <see cref="TrackMediaClient.GetTrackMedia"/>.
/// The network-memory bound rests on each forward fetch being a finite <c>bytes=start-end</c> slice (so the
/// browser buffers only one segment), and on the caller learning the file total from the 206
/// <c>Content-Range</c> header (the EOF boundary the segment cursor advances toward). Both are request/response
/// plumbing the harness can observe directly; the actual browser memory behaviour is Daniel's manual re-run.
/// </summary>
[TestFixture]
public class TrackMediaBoundedRangeTests
{
// Captures the outgoing Range header and returns a 206 with a Content-Range so TotalLength resolves.
private sealed class RangeCapturingHandler : HttpMessageHandler
{
private readonly long _total;
public RangeHeaderValue? CapturedRange { get; private set; }
public RangeCapturingHandler(long total) => _total = total;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
CapturedRange = request.Headers.Range;
var from = request.Headers.Range?.Ranges.First().From ?? 0;
var to = request.Headers.Range?.Ranges.First().To ?? (_total - 1);
var body = new byte[to - from + 1];
var response = new HttpResponseMessage(HttpStatusCode.PartialContent)
{
Content = new ByteArrayContent(body),
};
response.Content.Headers.ContentRange = new ContentRangeHeaderValue(from, to, _total);
return Task.FromResult(response);
}
}
private sealed class SingleClientFactory : IHttpClientFactory
{
private readonly HttpMessageHandler _handler;
public SingleClientFactory(HttpMessageHandler handler) => _handler = handler;
public HttpClient CreateClient(string name) =>
new(_handler, disposeHandler: false) { BaseAddress = new Uri("https://content.test/") };
}
[Test]
public async Task GetTrackMedia_WithByteEnd_SendsBoundedRange()
{
var handler = new RangeCapturingHandler(total: 100_000_000);
var client = new TrackMediaClient(new SingleClientFactory(handler));
await client.GetTrackMedia("track-1", byteOffset: 0, byteEnd: 4 * 1024 * 1024 - 1, format: AudioFormat.Lossless);
var range = handler.CapturedRange!.Ranges.First();
Assert.Multiple(() =>
{
Assert.That(range.From, Is.EqualTo(0), "bounded request starts at the cursor");
Assert.That(range.To, Is.EqualTo(4 * 1024 * 1024 - 1),
"bounded request must carry an inclusive end so the server returns a finite slice (one segment)");
});
}
[Test]
public async Task GetTrackMedia_WithoutByteEnd_SendsOpenEndedRange()
{
var handler = new RangeCapturingHandler(total: 100_000_000);
var client = new TrackMediaClient(new SingleClientFactory(handler));
await client.GetTrackMedia("track-1", byteOffset: 1024, byteEnd: null, format: AudioFormat.Lossless);
var range = handler.CapturedRange!.Ranges.First();
Assert.Multiple(() =>
{
Assert.That(range.From, Is.EqualTo(1024), "open-ended request starts at the cursor");
Assert.That(range.To, Is.Null, "no byteEnd → open-ended bytes=start- (pre-Direction-B shape, kept working)");
});
}
[Test]
public async Task GetTrackMedia_206Response_SurfacesTotalLengthFromContentRange()
{
var handler = new RangeCapturingHandler(total: 970_000_000);
var client = new TrackMediaClient(new SingleClientFactory(handler));
var result = await client.GetTrackMedia("track-1", byteOffset: 0, byteEnd: 4 * 1024 * 1024 - 1);
Assert.That(result.Success, Is.True);
Assert.That(result.Value!.TotalLength, Is.EqualTo(970_000_000),
"the file total (the segment cursor's EOF boundary) must come from the 206 Content-Range");
}
}