diff --git a/DeepDrftAPI/Controllers/TrackController.cs b/DeepDrftAPI/Controllers/TrackController.cs
index 4b69aef..d6daaea 100644
--- a/DeepDrftAPI/Controllers/TrackController.cs
+++ b/DeepDrftAPI/Controllers/TrackController.cs
@@ -403,8 +403,10 @@ public class TrackController : ControllerBase
_logger.LogInformation(
"Streaming track from disk: {TrackId}, Size: {Size} bytes",
trackId, streamLength);
- // enableRangeProcessing: false — seek is served by WavOffsetService, not Range.
- return File(innerStream, streamMimeType, enableRangeProcessing: false);
+ // enableRangeProcessing: true — seek is served by HTTP Range requests.
+ // The FileStream is seekable, so ASP.NET Core honours an incoming
+ // Range header by slicing the file and responding 206 Partial Content.
+ return File(innerStream, streamMimeType, enableRangeProcessing: true);
}
// Offset path: route through TrackContentService.GetAudioBinaryAsync (Track B's
diff --git a/DeepDrftPublic.Client/Clients/TrackMediaClient.cs b/DeepDrftPublic.Client/Clients/TrackMediaClient.cs
index e2c981d..d3bef1b 100644
--- a/DeepDrftPublic.Client/Clients/TrackMediaClient.cs
+++ b/DeepDrftPublic.Client/Clients/TrackMediaClient.cs
@@ -1,4 +1,5 @@
using System.Net;
+using System.Net.Http.Headers;
using System.Net.Http.Json;
using DeepDrftModels.DTOs;
using Microsoft.Extensions.DependencyInjection;
@@ -10,16 +11,19 @@ public class TrackMediaResponse : IDisposable
{
public Stream Stream { get; }
public long ContentLength { get; }
+ private readonly HttpResponseMessage _response;
- public TrackMediaResponse(Stream stream, long contentLength)
+ public TrackMediaResponse(Stream stream, long contentLength, HttpResponseMessage response)
{
Stream = stream;
ContentLength = contentLength;
+ _response = response;
}
public void Dispose()
{
Stream?.Dispose();
+ _response?.Dispose();
}
}
@@ -33,10 +37,12 @@ public class TrackMediaClient
}
///
- /// Fetches the WAV stream for a track, optionally starting from a byte offset.
- /// The cancellation token is forwarded to so a
- /// navigation or seek-replacement aborts the in-flight server connection rather
- /// than leaving the server draining bytes into a dead socket.
+ /// Fetches the WAV stream for a track via an HTTP Range request starting at a
+ /// file-absolute byte offset. is the position from
+ /// the start of the file on disk (including the WAV header) — callers seeking into
+ /// audio data must add the header size themselves. The cancellation token aborts
+ /// the in-flight server connection rather than leaving the server draining bytes
+ /// into a dead socket.
///
public async Task> GetTrackMedia(
string trackId,
@@ -45,19 +51,21 @@ public class TrackMediaClient
{
try
{
- // Build URL with optional offset parameter
- var url = byteOffset > 0
- ? $"api/track/{trackId}?offset={byteOffset}"
- : $"api/track/{trackId}";
+ // Same URL for every seek — only the Range header differs. byteOffset 0 is
+ // not special-cased: "bytes=0-" requests the whole file from the start.
+ using var request = new HttpRequestMessage(HttpMethod.Get, $"api/track/{trackId}");
+ request.Headers.Range = new RangeHeaderValue(byteOffset, null);
// Use HttpCompletionOption.ResponseHeadersRead to get stream immediately
- var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
+ var response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
var contentLength = response.Content.Headers.ContentLength ?? 0;
var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
- return ApiResult.CreatePassResult(new TrackMediaResponse(stream, contentLength));
+ // TrackMediaResponse takes ownership of both stream and response;
+ // do NOT dispose response here — the caller disposes via TrackMediaResponse.Dispose().
+ return ApiResult.CreatePassResult(new TrackMediaResponse(stream, contentLength, response));
}
catch (Exception e)
{
diff --git a/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs b/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs
index 0e5daff..ed89af2 100644
--- a/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs
+++ b/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs
@@ -377,7 +377,7 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
if (result.Success)
{
- if (result.SeekBeyondBuffer && result.ByteOffset > 0)
+ if (result.SeekBeyondBuffer && result.ByteOffset >= 0)
{
// Need to load new stream from offset
_logger.LogInformation("Seeking beyond buffer to {Position:F2}s, byte offset: {ByteOffset}",
diff --git a/DeepDrftPublic/Controllers/TrackProxyController.cs b/DeepDrftPublic/Controllers/TrackProxyController.cs
index 87438b6..3128a17 100644
--- a/DeepDrftPublic/Controllers/TrackProxyController.cs
+++ b/DeepDrftPublic/Controllers/TrackProxyController.cs
@@ -128,25 +128,33 @@ public class TrackProxyController : ControllerBase
}
///
- /// Proxies audio streaming from DeepDrftAPI. Passes the optional byte offset
- /// so seek-beyond-buffer works through the proxy without buffering.
+ /// Proxies audio streaming from DeepDrftAPI as a transparent HTTP Range relay.
+ /// Forwards the incoming Range header upstream and relays the upstream status
+ /// (200 full, 206 partial, 416 unsatisfiable) and range-related response headers
+ /// back to the browser verbatim. The proxy does not slice — the upstream already did.
///
[HttpGet("{trackId}")]
public async Task GetTrack(
string trackId,
- [FromQuery] long offset = 0,
CancellationToken ct = default)
{
- _logger.LogInformation("Proxying track {TrackId} offset {Offset}", trackId, offset);
+ var rangeHeader = Request.Headers.Range.ToString();
+ _logger.LogInformation("Proxying track {TrackId} range '{Range}'", trackId, rangeHeader);
- var path = offset == 0
- ? $"api/track/{Uri.EscapeDataString(trackId)}"
- : $"api/track/{Uri.EscapeDataString(trackId)}?offset={offset}";
+ var request = new HttpRequestMessage(
+ HttpMethod.Get,
+ $"api/track/{Uri.EscapeDataString(trackId)}");
+
+ // Forward the browser's Range header upstream so DeepDrftAPI slices the file.
+ // TryAddWithoutValidation avoids RangeHeaderValue reparsing — we relay the raw
+ // header verbatim, keeping the proxy transparent.
+ if (!string.IsNullOrEmpty(rangeHeader))
+ request.Headers.TryAddWithoutValidation("Range", rangeHeader);
HttpResponseMessage upstream;
try
{
- upstream = await _upstream.GetAsync(path, HttpCompletionOption.ResponseHeadersRead, ct);
+ upstream = await _upstream.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
}
catch (Exception ex)
{
@@ -154,6 +162,16 @@ public class TrackProxyController : ControllerBase
return StatusCode(502, "Upstream unavailable");
}
+ // 416 Range Not Satisfiable is a legitimate upstream answer (seek past EOF);
+ // relay it as-is rather than collapsing it into a 502.
+ if ((int)upstream.StatusCode == StatusCodes.Status416RangeNotSatisfiable)
+ {
+ upstream.Dispose();
+ return StatusCode(StatusCodes.Status416RangeNotSatisfiable);
+ }
+
+ // 206 Partial Content reports IsSuccessStatusCode == true, so this guard only
+ // catches genuine upstream failures (404, 5xx).
if (!upstream.IsSuccessStatusCode)
{
upstream.Dispose();
@@ -161,18 +179,35 @@ public class TrackProxyController : ControllerBase
return StatusCode((int)upstream.StatusCode);
}
- // Do NOT dispose upstream here — File() takes ownership of the response stream
- // and disposes it after the body is sent.
- var contentType = upstream.Content.Headers.ContentType?.ToString() ?? "audio/wav";
- var contentLength = upstream.Content.Headers.ContentLength;
+ // Stream the body manually rather than via File(): FileStreamResult forces the
+ // status to 200 (with enableRangeProcessing:false) and would clobber a relayed
+ // 206. Writing status + headers + body directly keeps the partial-content
+ // contract intact, with the proxy doing zero slicing of its own.
+ HttpContext.Response.RegisterForDispose(upstream);
+ Response.StatusCode = (int)upstream.StatusCode;
+ Response.ContentType = upstream.Content.Headers.ContentType?.ToString() ?? "audio/wav";
- // Forward Content-Length so the WASM player has duration info from the WAV header length.
- if (contentLength.HasValue)
- Response.ContentLength = contentLength.Value;
+ // Relay range-related headers so the browser sees the same partial-content
+ // contract the upstream emitted.
+ if (upstream.Headers.AcceptRanges.Count > 0)
+ Response.Headers.AcceptRanges = string.Join(", ", upstream.Headers.AcceptRanges);
+ if (upstream.Content.Headers.ContentRange is { } contentRange)
+ Response.Headers.ContentRange = contentRange.ToString();
+ if (upstream.Content.Headers.ContentLength is { } contentLength)
+ Response.ContentLength = contentLength;
var stream = await upstream.Content.ReadAsStreamAsync(ct);
- HttpContext.Response.RegisterForDispose(upstream);
- return File(stream, contentType, enableRangeProcessing: false);
+ try
+ {
+ await stream.CopyToAsync(Response.Body, ct);
+ return new EmptyResult();
+ }
+ catch (OperationCanceledException)
+ {
+ // Client navigated away or issued a new seek — nothing to do.
+ // The upstream connection is cleaned up via RegisterForDispose.
+ return new EmptyResult();
+ }
}
///
diff --git a/DeepDrftPublic/Interop/audio/AudioPlayer.ts b/DeepDrftPublic/Interop/audio/AudioPlayer.ts
index ee0c50a..17f2617 100644
--- a/DeepDrftPublic/Interop/audio/AudioPlayer.ts
+++ b/DeepDrftPublic/Interop/audio/AudioPlayer.ts
@@ -321,18 +321,28 @@ export class AudioPlayer {
*/
private seekBeyondBuffer(position: number): AudioResult {
try {
- const byteOffset = this.streamDecoder.calculateByteOffset(position);
+ const audioOffset = this.streamDecoder.calculateByteOffset(position);
// 0 is a valid offset (seek to start of audio data). Only a negative result
// indicates calculation failure — typically a missing/unparsed WAV header.
- if (byteOffset < 0) {
+ if (audioOffset < 0) {
return { success: false, error: 'Cannot calculate byte offset' };
}
- // Signal that C# needs to request new stream from offset
+ // The Range request is file-absolute: byte position from the start of the
+ // file on disk, header included. calculateByteOffset returns an audio-data-
+ // relative offset, so add headerSize to land on the right byte. (The old
+ // ?offset= contract was audio-relative; the server added the header itself.)
+ const header = this.streamDecoder.getWavHeader();
+ if (!header) {
+ return { success: false, error: 'Cannot calculate byte offset' };
+ }
+ const fileOffset = header.headerSize + audioOffset;
+
+ // Signal that C# needs to request a new stream from this file-absolute offset
return {
success: true,
seekBeyondBuffer: true,
- byteOffset: byteOffset
+ byteOffset: fileOffset
};
} catch (error) {
return { success: false, error: (error as Error).message };
@@ -368,8 +378,10 @@ export class AudioPlayer {
this.scheduler.clearForSeek();
this.scheduler.setPlaybackOffset(seekPosition);
- // Reinitialize decoder for new stream
- this.streamDecoder.reinitializeForOffset(totalStreamLength);
+ // Reinitialize decoder for the Range-continuation stream. totalStreamLength
+ // here is the 206 Content-Length (range start → EOF), not the full file size —
+ // the decoder uses it to detect stream-complete against raw audio bytes.
+ this.streamDecoder.reinitializeForRangeContinuation(totalStreamLength);
// Update state
this.pausePosition = seekPosition;
diff --git a/DeepDrftPublic/Interop/audio/StreamDecoder.ts b/DeepDrftPublic/Interop/audio/StreamDecoder.ts
index 52ba777..73a6c3a 100644
--- a/DeepDrftPublic/Interop/audio/StreamDecoder.ts
+++ b/DeepDrftPublic/Interop/audio/StreamDecoder.ts
@@ -60,6 +60,14 @@ export class StreamDecoder {
private streamComplete: boolean = false;
private headerError: string | null = null;
+ // Range-continuation state. After a seek-beyond-buffer the server responds 206
+ // with raw PCM from a file-absolute offset (no WAV header). We retain the header
+ // parsed from the initial stream and treat the whole body as audio data. The
+ // stream-complete check then counts raw bytes against the 206 Content-Length
+ // (remainingByteLength) rather than the full-file totalStreamLength + headerSize.
+ private isContinuation: boolean = false;
+ private remainingByteLength: number = 0;
+
// Pre-header accumulator. WAV headers can span multiple network chunks
// (small first segment, extended LIST/INFO/JUNK chunks before 'data', etc.),
// so we buffer raw bytes here until parseHeader succeeds rather than assuming
@@ -84,6 +92,8 @@ export class StreamDecoder {
this.headerBytesReceived = 0;
this.headerSearchChunks = [];
this.headerError = null;
+ this.isContinuation = false;
+ this.remainingByteLength = 0;
}
/**
@@ -183,6 +193,16 @@ export class StreamDecoder {
* otherwise pre-header bytes count toward the total.
*/
private updateStreamCompleteFlag(): void {
+ // Range-continuation: the 206 body is pure audio (no header), so compare raw
+ // audio bytes directly against the 206 Content-Length. Do NOT add headerSize —
+ // there is no header in this response.
+ if (this.isContinuation) {
+ if (this.remainingByteLength > 0 && this.totalRawBytes >= this.remainingByteLength) {
+ this.streamComplete = true;
+ }
+ return;
+ }
+
if (this.totalStreamLength <= 0) return;
const totalReceived = this.wavHeader
? this.totalRawBytes + this.wavHeader.headerSize
@@ -445,23 +465,34 @@ export class StreamDecoder {
this.headerBytesReceived = 0;
this.headerSearchChunks = [];
this.headerError = null;
+ this.isContinuation = false;
+ this.remainingByteLength = 0;
}
/**
- * Reinitialize for offset streaming - preserves header format knowledge
- * Called when seeking beyond buffer to prepare for new stream from server
+ * Reinitialize for a Range-continuation stream after seek-beyond-buffer.
+ *
+ * The server responds to a Range request with 206 Partial Content carrying raw
+ * PCM from a file-absolute offset — there is NO WAV header in this body. We retain
+ * the header parsed from the initial stream (its format describes every segment we
+ * synthesise via createWavFile) and feed the entire 206 body straight into the
+ * decode pipeline. The `if (!this.wavHeader)` branch in processChunk therefore goes
+ * directly to addRawData and tryParseHeader is never re-entered.
+ *
+ * @param remainingByteLength the Content-Length of the 206 response — the number of
+ * bytes from the range start to EOF, NOT the full file size. Stream-complete is
+ * reached when totalRawBytes >= this value.
*/
- reinitializeForOffset(totalStreamLength: number): void {
- // Reset data state but we'll get a fresh header from the offset stream
+ reinitializeForRangeContinuation(remainingByteLength: number): void {
+ // Retain this.wavHeader — the 206 body carries no header to reparse.
this.rawChunks = [];
this.totalRawBytes = 0;
this.processedBytes = 0;
- this.totalStreamLength = totalStreamLength;
this.streamComplete = false;
this.headerBytesReceived = 0;
this.headerSearchChunks = [];
this.headerError = null;
- // wavHeader will be reparsed from the new stream (server sends fresh header)
- this.wavHeader = null;
+ this.isContinuation = true;
+ this.remainingByteLength = remainingByteLength;
}
}