Fix streaming majors: PCM-only validation, stream-from-disk, ConcatStream offset, AsyncDisposable, HTTP cancellation, await ensureReady, seekBeyondBuffer offset-0 guard, negative WAV chunk guard

This commit is contained in:
Daniel Harvey
2026-05-17 16:57:20 -04:00
parent fc5b8de81a
commit 02d146ce02
12 changed files with 481 additions and 68 deletions
@@ -8,13 +8,23 @@ namespace DeepDrftContent.Services.Audio;
/// </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>MemoryStream with new WAV header + audio data from offset, or null if invalid</returns>
public MemoryStream? CreateOffsetStream(byte[] fullAudioBuffer, long byteOffset)
/// <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)
@@ -27,28 +37,44 @@ public class WavOffsetService
// Align to block boundary for clean audio
var alignedOffset = (byteOffset / format.BlockAlign) * format.BlockAlign;
// Calculate new data size
var newDataSize = format.DataSize - (int)alignedOffset;
// Calculate new data size (long arithmetic — DataSize may be up to ~4 GB)
var newDataSize = format.DataSize - alignedOffset;
if (newDataSize <= 0)
return null;
// Create new WAV header
var newHeader = CreateWavHeader(format, newDataSize);
// Calculate source position in original buffer
// 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");
// Create result stream: new header + audio data from offset
var resultStream = new MemoryStream(44 + newDataSize);
resultStream.Write(newHeader, 0, 44);
resultStream.Write(fullAudioBuffer, (int)sourcePosition, newDataSize);
resultStream.Position = 0;
var newDataSizeInt = (int)newDataSize;
var sourcePositionInt = (int)sourcePosition;
return resultStream;
// 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)
{
@@ -70,8 +96,9 @@ public class WavOffsetService
int bitsPerSample = 0;
int byteRate = 0;
int blockAlign = 0;
int dataSize = 0;
long dataSize = 0;
int headerSize = 0;
short audioFormat = 0;
bool foundFmt = false;
bool foundData = false;
@@ -82,14 +109,20 @@ public class WavOffsetService
var chunkId = Encoding.ASCII.GetString(buffer, chunkOffset, 4);
var chunkSize = BitConverter.ToInt32(buffer, chunkOffset + 4);
if (chunkSize < 0)
return null;
if (chunkId == "fmt ")
{
if (chunkSize < 16)
return null;
var audioFormat = BitConverter.ToInt16(buffer, chunkOffset + 8);
// Support PCM (1) and IEEE Float (3) formats
if (audioFormat != 1 && audioFormat != 3)
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);
@@ -106,7 +139,9 @@ public class WavOffsetService
}
else if (chunkId == "data")
{
dataSize = chunkSize;
// WAV stores DataSize as a 32-bit unsigned int. Read as uint to preserve
// values above int.MaxValue (files between 24 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;
}
@@ -124,6 +159,7 @@ public class WavOffsetService
return null;
return new WavFormat(
AudioFormat: audioFormat,
SampleRate: sampleRate,
Channels: channels,
BitsPerSample: bitsPerSample,
@@ -135,7 +171,9 @@ public class WavOffsetService
}
/// <summary>
/// Creates a standard 44-byte PCM WAV header.
/// 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)
{
@@ -150,7 +188,7 @@ public class WavOffsetService
// 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((short)1).CopyTo(header, 20); // Audio format (PCM)
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);
@@ -168,12 +206,110 @@ public class WavOffsetService
/// <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,
int DataSize,
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);
}
}
@@ -84,6 +84,53 @@ public abstract class MediaVault : VaultIndexDirectory
return (T)result;
}
/// <summary>
/// Opens a read-only stream over an entry's backing file plus its metadata
/// (extension/MIME), without buffering the file into memory.
/// Returns null if the entry is unknown or the backing file is missing.
///
/// Use this when the caller will forward bytes to a network response — the
/// existing <see cref="GetEntryAsync{T}"/> allocates a full <c>byte[]</c>
/// and pushes large WAVs onto the LOH for every request.
///
/// The caller owns the returned stream and must dispose it. Error-handling
/// follows the same swallow-and-return-null contract as the rest of the
/// FileDatabase API; the caller checks for null.
/// </summary>
public async Task<MediaStream?> GetEntryStreamAsync(string entryId)
{
try
{
if (!await HasIndexEntry(entryId))
return null;
var metaData = await GetEntryMetadata(entryId);
if (metaData == null)
return null;
var mediaPath = GetMediaPathFromEntryKey(metaData.MediaKey, metaData.Extension);
if (!FileUtils.FileExists(mediaPath))
return null;
// Async-capable, sequential-scan FileStream — the response writer will pull
// bytes in order. bufferSize matches FileUtils.FetchFileAsync (64 KB).
var stream = new FileStream(
mediaPath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 64 * 1024,
useAsync: true);
return new MediaStream(stream, metaData.Extension);
}
catch
{
// Match FileDatabase error-swallow contract.
return null;
}
}
/// <summary>
/// Extracts buffer and extension from a media binary
/// </summary>
@@ -127,7 +174,7 @@ public class ImageVault : MediaVault
public class AudioVault : MediaVault
{
private AudioVault(string rootPath, VaultIndex index) : base(rootPath, index) { }
public static async Task<AudioVault?> FromAsync(string rootPath)
{
var factoryService = new IndexFactoryService();
@@ -141,3 +188,22 @@ public class AudioVault : MediaVault
return null;
}
}
/// <summary>
/// An open read-only stream over a vault entry plus the extension needed to
/// resolve its MIME type. Caller owns the stream and must dispose it.
/// </summary>
public sealed class MediaStream : IDisposable, IAsyncDisposable
{
public Stream Stream { get; }
public string Extension { get; }
public MediaStream(Stream stream, string extension)
{
Stream = stream;
Extension = extension;
}
public void Dispose() => Stream.Dispose();
public ValueTask DisposeAsync() => Stream.DisposeAsync();
}