chore: remove WavOffsetService and ?offset= seek path, superseded by Range header (Phase 4.1)
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
using DeepDrftAPI.Middleware;
|
||||
using DeepDrftAPI.Models;
|
||||
using DeepDrftAPI.Services;
|
||||
using DeepDrftContent.Audio;
|
||||
using DeepDrftContent.Constants;
|
||||
using DeepDrftContent.FileDatabase.Services;
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
@@ -17,7 +16,6 @@ namespace DeepDrftAPI.Controllers;
|
||||
public class TrackController : ControllerBase
|
||||
{
|
||||
private readonly DeepDrftContent.TrackContentService _trackContentService;
|
||||
private readonly WavOffsetService _wavOffsetService;
|
||||
private readonly UnifiedTrackService _unifiedService;
|
||||
private readonly ITrackService _sqlTrackService;
|
||||
private readonly WaveformProfileService _waveformProfileService;
|
||||
@@ -32,7 +30,6 @@ public class TrackController : ControllerBase
|
||||
public TrackController(
|
||||
DeepDrftContent.TrackContentService trackContentService,
|
||||
DeepDrftContent.FileDatabase.Services.FileDatabase fileDatabase,
|
||||
WavOffsetService wavOffsetService,
|
||||
UnifiedTrackService unifiedService,
|
||||
ITrackService sqlTrackService,
|
||||
WaveformProfileService waveformProfileService,
|
||||
@@ -40,7 +37,6 @@ public class TrackController : ControllerBase
|
||||
{
|
||||
_trackContentService = trackContentService;
|
||||
_fileDatabase = fileDatabase;
|
||||
_wavOffsetService = wavOffsetService;
|
||||
_unifiedService = unifiedService;
|
||||
_sqlTrackService = sqlTrackService;
|
||||
_waveformProfileService = waveformProfileService;
|
||||
@@ -352,86 +348,55 @@ public class TrackController : ControllerBase
|
||||
// --- Parameterized routes ---
|
||||
|
||||
[HttpGet("{trackId}")]
|
||||
public async Task<ActionResult> GetTrack(string trackId, [FromQuery] long offset = 0)
|
||||
public async Task<ActionResult> GetTrack(string trackId)
|
||||
{
|
||||
_logger.LogInformation("GetTrack called with trackId: {TrackId}, offset: {Offset}", trackId, offset);
|
||||
_logger.LogInformation("GetTrack called with trackId: {TrackId}", trackId);
|
||||
|
||||
try
|
||||
{
|
||||
// No-offset path: stream the file straight from disk so a 100 MB WAV does not
|
||||
// force a 100 MB LOH allocation per request. The offset path still loads
|
||||
// the full buffer because WavOffsetService block-aligns and reslices into
|
||||
// a composite stream over the in-memory buffer.
|
||||
if (offset == 0)
|
||||
var vault = _fileDatabase.GetVault(VaultConstants.Tracks);
|
||||
if (vault == null)
|
||||
{
|
||||
var vault = _fileDatabase.GetVault(VaultConstants.Tracks);
|
||||
if (vault == null)
|
||||
{
|
||||
_logger.LogWarning("Tracks vault not found");
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var mediaStream = await vault.GetEntryStreamAsync(trackId);
|
||||
if (mediaStream == null)
|
||||
{
|
||||
_logger.LogWarning("Track not found: {TrackId}", trackId);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Resolve MIME and log before handing the stream to File().
|
||||
// If anything here throws, the finally block disposes the wrapper
|
||||
// (and its inner FileStream) so neither leaks. On the success path
|
||||
// File() takes ownership of the inner stream; ASP.NET Core disposes
|
||||
// it after the response body is sent. The wrapper is a thin struct
|
||||
// with no extra resources, so disposing it after extracting the
|
||||
// inner stream is a no-op — we only call Dispose() in the catch path.
|
||||
string streamMimeType;
|
||||
long streamLength;
|
||||
Stream innerStream;
|
||||
try
|
||||
{
|
||||
streamMimeType = MimeTypeExtensions.GetMimeType(mediaStream.Extension);
|
||||
streamLength = mediaStream.Stream.Length;
|
||||
innerStream = mediaStream.Stream;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await mediaStream.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Streaming track from disk: {TrackId}, Size: {Size} bytes",
|
||||
trackId, streamLength);
|
||||
// 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);
|
||||
_logger.LogWarning("Tracks vault not found");
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Offset path: route through TrackContentService.GetAudioBinaryAsync (Track B's
|
||||
// orchestrator boundary) so the controller stays out of FileDatabase directly.
|
||||
// The buffered AudioBinary is required because WavOffsetService block-aligns
|
||||
// and reslices into a composite stream over the in-memory buffer.
|
||||
var file = await _trackContentService.GetAudioBinaryAsync(trackId);
|
||||
if (file == null)
|
||||
var mediaStream = await vault.GetEntryStreamAsync(trackId);
|
||||
if (mediaStream == null)
|
||||
{
|
||||
_logger.LogWarning("Track not found: {TrackId}", trackId);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var mimeType = MimeTypeExtensions.GetMimeType(file.Extension);
|
||||
|
||||
var offsetStream = _wavOffsetService.CreateOffsetStream(file.Buffer, offset);
|
||||
if (offsetStream == null)
|
||||
// Resolve MIME and log before handing the stream to File().
|
||||
// If anything here throws, the finally block disposes the wrapper
|
||||
// (and its inner FileStream) so neither leaks. On the success path
|
||||
// File() takes ownership of the inner stream; ASP.NET Core disposes
|
||||
// it after the response body is sent. The wrapper is a thin struct
|
||||
// with no extra resources, so disposing it after extracting the
|
||||
// inner stream is a no-op — we only call Dispose() in the catch path.
|
||||
string streamMimeType;
|
||||
long streamLength;
|
||||
Stream innerStream;
|
||||
try
|
||||
{
|
||||
_logger.LogWarning("Invalid offset {Offset} for track: {TrackId}", offset, trackId);
|
||||
return BadRequest("Invalid offset");
|
||||
streamMimeType = MimeTypeExtensions.GetMimeType(mediaStream.Extension);
|
||||
streamLength = mediaStream.Stream.Length;
|
||||
innerStream = mediaStream.Stream;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await mediaStream.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Successfully retrieved track with offset: {TrackId}, Offset: {Offset}, StreamSize: {Size} bytes",
|
||||
trackId, offset, offsetStream.Length);
|
||||
return File(offsetStream, mimeType);
|
||||
_logger.LogInformation(
|
||||
"Streaming track from disk: {TrackId}, Size: {Size} bytes",
|
||||
trackId, streamLength);
|
||||
// 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);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using DeepDrftAPI.Models;
|
||||
using DeepDrftContent;
|
||||
using DeepDrftContent.Audio;
|
||||
using DeepDrftContent.Constants;
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
using DeepDrftContent.FileDatabase.Services;
|
||||
@@ -15,7 +14,6 @@ namespace DeepDrftAPI
|
||||
public static Task ConfigureDomainServices(WebApplicationBuilder builder)
|
||||
{
|
||||
// Audio services
|
||||
builder.Services.AddSingleton<WavOffsetService>();
|
||||
builder.Services.AddSingleton<AudioProcessor>();
|
||||
builder.Services.AddSingleton<TrackContentService>();
|
||||
|
||||
|
||||
@@ -1,318 +0,0 @@
|
||||
using System.Text;
|
||||
|
||||
namespace DeepDrftContent.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating WAV audio streams starting from a byte offset.
|
||||
/// Synthesizes a valid WAV header for the remaining audio data.
|
||||
/// </summary>
|
||||
public class WavOffsetService
|
||||
{
|
||||
/// <summary>
|
||||
/// WAV audio format code for linear PCM. The pipeline (AudioProcessor,
|
||||
/// WavOffsetService, and wavutils.ts) is PCM-only by design — IEEE Float
|
||||
/// (format 3) and other formats are rejected at parse time so the
|
||||
/// synthesized header here can safely assume PCM.
|
||||
/// </summary>
|
||||
public const short PcmFormat = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a stream containing a synthesized WAV header followed by audio data from the specified offset.
|
||||
/// The returned stream is composed of a small header buffer and a non-owning slice over the input
|
||||
/// buffer — no copy of the audio payload is made.
|
||||
/// </summary>
|
||||
/// <param name="fullAudioBuffer">The complete WAV file buffer</param>
|
||||
/// <param name="byteOffset">Byte offset into the raw audio data (not including original header)</param>
|
||||
/// <returns>Stream with new WAV header + audio data from offset, or null if invalid</returns>
|
||||
public Stream? CreateOffsetStream(byte[] fullAudioBuffer, long byteOffset)
|
||||
{
|
||||
var format = ParseWavHeader(fullAudioBuffer);
|
||||
if (format == null)
|
||||
return null;
|
||||
|
||||
// Validate offset is within bounds and block-aligned
|
||||
if (byteOffset < 0 || byteOffset >= format.DataSize)
|
||||
return null;
|
||||
|
||||
// Align to block boundary for clean audio
|
||||
var alignedOffset = (byteOffset / format.BlockAlign) * format.BlockAlign;
|
||||
|
||||
// Calculate new data size (long arithmetic — DataSize may be up to ~4 GB)
|
||||
var newDataSize = format.DataSize - alignedOffset;
|
||||
if (newDataSize <= 0)
|
||||
return null;
|
||||
|
||||
// MemoryStream does not support offsets or lengths beyond int.MaxValue.
|
||||
// RF64 (>2 GB audio segments) is not supported; reject before truncating.
|
||||
var sourcePosition = format.HeaderSize + alignedOffset;
|
||||
if (sourcePosition > int.MaxValue || newDataSize > int.MaxValue)
|
||||
throw new NotSupportedException("Audio file segment exceeds 2 GB; RF64 not supported");
|
||||
|
||||
var newDataSizeInt = (int)newDataSize;
|
||||
var sourcePositionInt = (int)sourcePosition;
|
||||
|
||||
// Create new WAV header using the format reported by the parsed header.
|
||||
// PCM is the only format we accept (see PcmFormat / ParseWavHeader), but
|
||||
// threading format.AudioFormat through keeps the header self-consistent
|
||||
// and prevents drift if the validation contract is ever relaxed.
|
||||
var newHeader = CreateWavHeader(format, newDataSizeInt);
|
||||
|
||||
// Compose: 44-byte header followed by a non-copying slice of the audio payload.
|
||||
// Wrapping the original buffer in a MemoryStream window avoids a 100MB+ copy
|
||||
// that the previous MemoryStream(capacity).Write(...) implementation forced.
|
||||
var headerStream = new MemoryStream(newHeader, writable: false);
|
||||
var dataStream = new MemoryStream(
|
||||
fullAudioBuffer,
|
||||
sourcePositionInt,
|
||||
newDataSizeInt,
|
||||
writable: false,
|
||||
publiclyVisible: false);
|
||||
|
||||
return new ConcatStream(headerStream, dataStream);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the WAV header from a buffer to extract format information.
|
||||
/// PCM-only — IEEE Float (format 3) and other non-PCM formats are rejected
|
||||
/// so downstream synthesis can safely assume PCM sample encoding.
|
||||
/// </summary>
|
||||
public WavFormat? ParseWavHeader(byte[] buffer)
|
||||
{
|
||||
if (buffer.Length < 44)
|
||||
return null;
|
||||
|
||||
// Check RIFF header
|
||||
var riff = Encoding.ASCII.GetString(buffer, 0, 4);
|
||||
if (riff != "RIFF")
|
||||
return null;
|
||||
|
||||
var wave = Encoding.ASCII.GetString(buffer, 8, 4);
|
||||
if (wave != "WAVE")
|
||||
return null;
|
||||
|
||||
// Variables to store parsed header info
|
||||
int sampleRate = 0;
|
||||
int channels = 0;
|
||||
int bitsPerSample = 0;
|
||||
int byteRate = 0;
|
||||
int blockAlign = 0;
|
||||
long dataSize = 0;
|
||||
int headerSize = 0;
|
||||
short audioFormat = 0;
|
||||
bool foundFmt = false;
|
||||
bool foundData = false;
|
||||
|
||||
// Find fmt and data chunks
|
||||
int chunkOffset = 12;
|
||||
while (chunkOffset < buffer.Length - 8)
|
||||
{
|
||||
var chunkId = Encoding.ASCII.GetString(buffer, chunkOffset, 4);
|
||||
var chunkSize = BitConverter.ToInt32(buffer, chunkOffset + 4);
|
||||
|
||||
if (chunkSize < 0)
|
||||
return null;
|
||||
|
||||
if (chunkId == "fmt " && !foundFmt)
|
||||
{
|
||||
// Use the first fmt chunk encountered — that is the WAV-spec-authoritative
|
||||
// chunk. Subsequent fmt chunks in a malformed file are ignored, matching
|
||||
// AudioProcessor.FindChunk which also returns the first match.
|
||||
if (chunkSize < 16)
|
||||
return null;
|
||||
|
||||
audioFormat = BitConverter.ToInt16(buffer, chunkOffset + 8);
|
||||
// PCM only. Float32 WAVs were previously accepted here but the synthesized
|
||||
// header below is PCM-shaped — accepting Float would produce a corrupt file
|
||||
// claiming PCM with Float-encoded samples. AudioProcessor also rejects
|
||||
// non-PCM at upload time so this branch is defense in depth.
|
||||
if (audioFormat != PcmFormat)
|
||||
return null;
|
||||
|
||||
channels = BitConverter.ToInt16(buffer, chunkOffset + 10);
|
||||
sampleRate = BitConverter.ToInt32(buffer, chunkOffset + 12);
|
||||
byteRate = BitConverter.ToInt32(buffer, chunkOffset + 16);
|
||||
blockAlign = BitConverter.ToInt16(buffer, chunkOffset + 20);
|
||||
bitsPerSample = BitConverter.ToInt16(buffer, chunkOffset + 22);
|
||||
|
||||
// Basic validation
|
||||
if (channels < 1 || channels > 8)
|
||||
return null;
|
||||
|
||||
foundFmt = true;
|
||||
}
|
||||
else if (chunkId == "data")
|
||||
{
|
||||
// WAV stores DataSize as a 32-bit unsigned int. Read as uint to preserve
|
||||
// values above int.MaxValue (files between 2–4 GB), then widen to long.
|
||||
dataSize = (long)BitConverter.ToUInt32(buffer, chunkOffset + 4);
|
||||
headerSize = chunkOffset + 8; // Audio data starts after 'data' + size (8 bytes)
|
||||
foundData = true;
|
||||
}
|
||||
|
||||
// Move to next chunk with proper alignment (chunks are word-aligned)
|
||||
chunkOffset += 8 + ((chunkSize + 1) & ~1);
|
||||
|
||||
// If we found both chunks, we're done
|
||||
if (foundFmt && foundData)
|
||||
break;
|
||||
}
|
||||
|
||||
// Must have found both fmt and data chunks
|
||||
if (!foundFmt || !foundData)
|
||||
return null;
|
||||
|
||||
return new WavFormat(
|
||||
AudioFormat: audioFormat,
|
||||
SampleRate: sampleRate,
|
||||
Channels: channels,
|
||||
BitsPerSample: bitsPerSample,
|
||||
ByteRate: byteRate,
|
||||
BlockAlign: blockAlign,
|
||||
DataSize: dataSize,
|
||||
HeaderSize: headerSize
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a standard 44-byte WAV header. The audio format code is taken from
|
||||
/// <paramref name="format"/> rather than hardcoded so the synthesized header matches
|
||||
/// what was parsed (today always <see cref="PcmFormat"/>; see ParseWavHeader).
|
||||
/// </summary>
|
||||
public byte[] CreateWavHeader(WavFormat format, int dataSize)
|
||||
{
|
||||
var header = new byte[44];
|
||||
var fileSize = 36 + dataSize;
|
||||
|
||||
// RIFF header
|
||||
header[0] = (byte)'R'; header[1] = (byte)'I'; header[2] = (byte)'F'; header[3] = (byte)'F';
|
||||
BitConverter.GetBytes(fileSize).CopyTo(header, 4);
|
||||
header[8] = (byte)'W'; header[9] = (byte)'A'; header[10] = (byte)'V'; header[11] = (byte)'E';
|
||||
|
||||
// fmt chunk
|
||||
header[12] = (byte)'f'; header[13] = (byte)'m'; header[14] = (byte)'t'; header[15] = (byte)' ';
|
||||
BitConverter.GetBytes(16).CopyTo(header, 16); // fmt chunk size
|
||||
BitConverter.GetBytes(format.AudioFormat).CopyTo(header, 20); // Audio format (from parsed header)
|
||||
BitConverter.GetBytes((short)format.Channels).CopyTo(header, 22);
|
||||
BitConverter.GetBytes(format.SampleRate).CopyTo(header, 24);
|
||||
BitConverter.GetBytes(format.ByteRate).CopyTo(header, 28);
|
||||
BitConverter.GetBytes((short)format.BlockAlign).CopyTo(header, 32);
|
||||
BitConverter.GetBytes((short)format.BitsPerSample).CopyTo(header, 34);
|
||||
|
||||
// data chunk header
|
||||
header[36] = (byte)'d'; header[37] = (byte)'a'; header[38] = (byte)'t'; header[39] = (byte)'a';
|
||||
BitConverter.GetBytes(dataSize).CopyTo(header, 40);
|
||||
|
||||
return header;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WAV format information extracted from header.
|
||||
/// </summary>
|
||||
/// <param name="AudioFormat">WAV fmt-chunk audio format code (1 = PCM; the only value accepted today).</param>
|
||||
public record WavFormat(
|
||||
short AudioFormat,
|
||||
int SampleRate,
|
||||
int Channels,
|
||||
int BitsPerSample,
|
||||
int ByteRate,
|
||||
int BlockAlign,
|
||||
long DataSize,
|
||||
int HeaderSize
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Forward-only read stream over two underlying streams concatenated end-to-end.
|
||||
/// Lets us serve "[synthesized header][slice of original buffer]" without
|
||||
/// allocating a single contiguous buffer for the combined payload.
|
||||
/// </summary>
|
||||
internal sealed class ConcatStream : Stream
|
||||
{
|
||||
private readonly Stream _first;
|
||||
private readonly Stream _second;
|
||||
private readonly long _length;
|
||||
private long _position;
|
||||
|
||||
public ConcatStream(Stream first, Stream second)
|
||||
{
|
||||
_first = first;
|
||||
_second = second;
|
||||
_length = first.Length + second.Length;
|
||||
}
|
||||
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => _length;
|
||||
|
||||
public override long Position
|
||||
{
|
||||
get => _position;
|
||||
set => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
var total = 0;
|
||||
|
||||
// Loop over _first until it returns 0 (exhausted) or the caller's buffer
|
||||
// is full. Stream.Read is not required to fill the buffer in one call even
|
||||
// when data is available (e.g. a future non-MemoryStream _first), so we must
|
||||
// keep pulling until we get 0 before advancing to _second.
|
||||
while (count > 0 && _position < _first.Length)
|
||||
{
|
||||
var read = _first.Read(buffer, offset, count);
|
||||
if (read == 0) break;
|
||||
total += read;
|
||||
_position += read;
|
||||
offset += read;
|
||||
count -= read;
|
||||
}
|
||||
|
||||
if (count > 0)
|
||||
{
|
||||
var read = _second.Read(buffer, offset, count);
|
||||
total += read;
|
||||
_position += read;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var total = 0;
|
||||
|
||||
// Same loop contract as Read() — exhaust _first before reading _second.
|
||||
while (!buffer.IsEmpty && _position < _first.Length)
|
||||
{
|
||||
var read = await _first.ReadAsync(buffer, cancellationToken);
|
||||
if (read == 0) break;
|
||||
total += read;
|
||||
_position += read;
|
||||
buffer = buffer[read..];
|
||||
}
|
||||
|
||||
if (!buffer.IsEmpty)
|
||||
{
|
||||
var read = await _second.ReadAsync(buffer, cancellationToken);
|
||||
total += read;
|
||||
_position += read;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
public override void Flush() { }
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_first.Dispose();
|
||||
_second.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user