From f602eb9772faccf9134307c6a580df34c160aef6 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 9 Jun 2026 07:30:36 -0400 Subject: [PATCH] chore: remove WavOffsetService and ?offset= seek path, superseded by Range header (Phase 4.1) --- DeepDrftAPI/Controllers/TrackController.cs | 103 +++---- DeepDrftAPI/Startup.cs | 2 - DeepDrftContent/Audio/WavOffsetService.cs | 318 --------------------- 3 files changed, 34 insertions(+), 389 deletions(-) delete mode 100644 DeepDrftContent/Audio/WavOffsetService.cs diff --git a/DeepDrftAPI/Controllers/TrackController.cs b/DeepDrftAPI/Controllers/TrackController.cs index d6daaea..63a5514 100644 --- a/DeepDrftAPI/Controllers/TrackController.cs +++ b/DeepDrftAPI/Controllers/TrackController.cs @@ -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 GetTrack(string trackId, [FromQuery] long offset = 0) + public async Task 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) { diff --git a/DeepDrftAPI/Startup.cs b/DeepDrftAPI/Startup.cs index a55fb49..852f062 100644 --- a/DeepDrftAPI/Startup.cs +++ b/DeepDrftAPI/Startup.cs @@ -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(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/DeepDrftContent/Audio/WavOffsetService.cs b/DeepDrftContent/Audio/WavOffsetService.cs deleted file mode 100644 index 1dccf7b..0000000 --- a/DeepDrftContent/Audio/WavOffsetService.cs +++ /dev/null @@ -1,318 +0,0 @@ -using System.Text; - -namespace DeepDrftContent.Audio; - -/// -/// Service for creating WAV audio streams starting from a byte offset. -/// Synthesizes a valid WAV header for the remaining audio data. -/// -public class WavOffsetService -{ - /// - /// 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. - /// - public const short PcmFormat = 1; - - /// - /// 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. - /// - /// The complete WAV file buffer - /// Byte offset into the raw audio data (not including original header) - /// Stream with new WAV header + audio data from offset, or null if invalid - 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); - } - - /// - /// 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. - /// - 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 - ); - } - - /// - /// Creates a standard 44-byte WAV header. The audio format code is taken from - /// rather than hardcoded so the synthesized header matches - /// what was parsed (today always ; see ParseWavHeader). - /// - 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; - } -} - -/// -/// WAV format information extracted from header. -/// -/// WAV fmt-chunk audio format code (1 = PCM; the only value accepted today). -public record WavFormat( - short AudioFormat, - int SampleRate, - int Channels, - int BitsPerSample, - int ByteRate, - int BlockAlign, - long DataSize, - int HeaderSize -); - -/// -/// 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. -/// -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 ReadAsync(Memory 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); - } -}