fix(api): stream audio store path to eliminate whole-file buffering (OOM)
Processors now emit a ProcessedAudio plan with a streamed writer instead of a whole-file AudioBinary; vault writes stream via RegisterResourceStreamingAsync. Header parsing is bounded. Wave 2 (waveform/Opus) still re-reads the full file by design.
This commit is contained in:
@@ -32,13 +32,18 @@ public class AudioProcessorTests
|
||||
[Test]
|
||||
public async Task StandardPcm_RoundTripsUnchanged()
|
||||
{
|
||||
var path = await WriteWavAsync(BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 16, audioFormat: WaveFormatPcm));
|
||||
var source = BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 16, audioFormat: WaveFormatPcm);
|
||||
var path = await WriteWavAsync(source);
|
||||
|
||||
var audio = await new AudioProcessor().ProcessWavFileAsync(path);
|
||||
|
||||
Assert.That(audio, Is.Not.Null);
|
||||
Assert.That(audio!.Duration, Is.GreaterThan(0.0));
|
||||
Assert.That(audio.Bitrate, Is.GreaterThan(0));
|
||||
// Standard PCM is passthrough: the streamed bytes must be byte-identical to the source file.
|
||||
var stored = await Materialize(audio);
|
||||
Assert.That(stored, Is.EqualTo(source), "Standard PCM must be stored byte-identical (passthrough)");
|
||||
Assert.That(audio.Size, Is.EqualTo(source.Length), "Passthrough Size must equal the source length");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -54,7 +59,8 @@ public class AudioProcessorTests
|
||||
Assert.That(audio, Is.Not.Null);
|
||||
Assert.That(audio!.Duration, Is.GreaterThan(0.0));
|
||||
Assert.That(audio.Bitrate, Is.GreaterThan(0));
|
||||
Assert.That(ReadFmtAudioFormat(audio.Buffer), Is.EqualTo(WaveFormatPcm), "Stored buffer must be standard PCM");
|
||||
var stored = await Materialize(audio);
|
||||
Assert.That(ReadFmtAudioFormat(stored), Is.EqualTo(WaveFormatPcm), "Stored buffer must be standard PCM");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -70,17 +76,19 @@ public class AudioProcessorTests
|
||||
var audio = await new AudioProcessor().ProcessWavFileAsync(path);
|
||||
|
||||
Assert.That(audio, Is.Not.Null);
|
||||
Assert.That(ReadFmtBitsPerSample(audio!.Buffer), Is.EqualTo(16 + 8), "Float must convert to 24-bit PCM");
|
||||
Assert.That(ReadFmtAudioFormat(audio.Buffer), Is.EqualTo(WaveFormatPcm));
|
||||
var stored = await Materialize(audio!);
|
||||
Assert.That(ReadFmtBitsPerSample(stored), Is.EqualTo(16 + 8), "Float must convert to 24-bit PCM");
|
||||
Assert.That(ReadFmtAudioFormat(stored), Is.EqualTo(WaveFormatPcm));
|
||||
// 4 float samples (4 bytes each) → 4 PCM samples (3 bytes each) = 12 data bytes after the 44-byte header.
|
||||
Assert.That(audio.Buffer.Length, Is.EqualTo(44 + 12));
|
||||
Assert.That(stored.Length, Is.EqualTo(44 + 12));
|
||||
Assert.That(audio!.Size, Is.EqualTo(44 + 12), "Computed Size must match the streamed byte count");
|
||||
// Verify the converted sample values: (int)(sample * 8388607.0), clamped, little-endian 3 bytes.
|
||||
// 0.5f → 4194303 = 0x3FFFFF → FF FF 3F
|
||||
// -0.5f → -4194303 = 0xFFC00001 → 24-bit LE: 01 00 C0
|
||||
// 1.0f → 8388607 = 0x7FFFFF → FF FF 7F
|
||||
// -1.0f → -8388607 = 0xFF800001 → 24-bit LE: 01 00 80
|
||||
var expectedData = new byte[] { 0xFF, 0xFF, 0x3F, 0x01, 0x00, 0xC0, 0xFF, 0xFF, 0x7F, 0x01, 0x00, 0x80 };
|
||||
var actualData = audio.Buffer[44..];
|
||||
var actualData = stored[44..];
|
||||
Assert.That(actualData, Is.EqualTo(expectedData), "Float samples must be converted to 24-bit PCM correctly");
|
||||
}
|
||||
|
||||
@@ -97,17 +105,18 @@ public class AudioProcessorTests
|
||||
var audio = await new AudioProcessor().ProcessWavFileAsync(path);
|
||||
|
||||
Assert.That(audio, Is.Not.Null);
|
||||
Assert.That(ReadFmtBitsPerSample(audio!.Buffer), Is.EqualTo(24), "Padded container must repack to 24-bit");
|
||||
Assert.That(ReadFmtAudioFormat(audio.Buffer), Is.EqualTo(WaveFormatPcm));
|
||||
var stored = await Materialize(audio!);
|
||||
Assert.That(ReadFmtBitsPerSample(stored), Is.EqualTo(24), "Padded container must repack to 24-bit");
|
||||
Assert.That(ReadFmtAudioFormat(stored), Is.EqualTo(WaveFormatPcm));
|
||||
// 4 container samples (4 bytes each) → 4 PCM samples (3 bytes each) = 12 data bytes.
|
||||
Assert.That(audio.Buffer.Length, Is.EqualTo(44 + 12));
|
||||
Assert.That(stored.Length, Is.EqualTo(44 + 12));
|
||||
// Verify the repacked sample values: lowest 3 bytes of each 4-byte little-endian container.
|
||||
// 0x123456 → LE 4 bytes: 56 34 12 00 → keep 3: 56 34 12
|
||||
// 0xFFEDCBA9 → LE 4 bytes: A9 CB ED FF → keep 3: A9 CB ED
|
||||
// 0x000001 → LE 4 bytes: 01 00 00 00 → keep 3: 01 00 00
|
||||
// 0xFF800000 → LE 4 bytes: 00 00 80 FF → keep 3: 00 00 80
|
||||
var expectedData = new byte[] { 0x56, 0x34, 0x12, 0xA9, 0xCB, 0xED, 0x01, 0x00, 0x00, 0x00, 0x00, 0x80 };
|
||||
var actualData = audio.Buffer[44..];
|
||||
var actualData = stored[44..];
|
||||
Assert.That(actualData, Is.EqualTo(expectedData), "Padded 24-in-32 samples must strip the padding byte correctly");
|
||||
}
|
||||
|
||||
@@ -265,6 +274,15 @@ public class AudioProcessorTests
|
||||
|
||||
// -- helpers --------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Streams a <see cref="ProcessedAudio"/> store plan into memory so its canonical bytes
|
||||
/// can be asserted — the store path no longer hands back a materialized buffer.</summary>
|
||||
private static async Task<byte[]> Materialize(ProcessedAudio audio)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
await audio.WriteToAsync(ms);
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synthesises a minimal valid MPEG1 Layer III CBR MP3 buffer: one frame header plus enough body
|
||||
/// bytes for the frame, with an optional Xing VBR header in the side-information region. The body
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
using System.Text;
|
||||
using DeepDrftContent;
|
||||
using DeepDrftContent.Constants;
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
using DeepDrftContent.Processors;
|
||||
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
|
||||
|
||||
namespace DeepDrftTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the streamed audio store path (Wave 1 OOM fix): processors emit a
|
||||
/// <see cref="ProcessedAudio"/> plan whose body is written to the vault without ever materializing
|
||||
/// the whole file in a managed <c>byte[]</c>. Covers passthrough byte-identity (standard-PCM WAV,
|
||||
/// MP3, FLAC), streamed WAV normalization (EXTENSIBLE), the new streaming vault register round-trip,
|
||||
/// and the memory-bounding contract of the store primitive (bounded-buffer, sequential, forward-only
|
||||
/// writes — never a single whole-file write).
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class AudioStoreStreamingTests
|
||||
{
|
||||
private const ushort WaveFormatPcm = 0x0001;
|
||||
private const ushort WaveFormatExtensible = 0xFFFE;
|
||||
|
||||
private string _testDir = string.Empty;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_testDir = Path.Combine(Path.GetTempPath(), "AudioStoreStreamingTests", Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(_testDir);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
try { Directory.Delete(_testDir, recursive: true); }
|
||||
catch { /* Best-effort cleanup — ignore failures */ }
|
||||
}
|
||||
|
||||
private static AudioProcessorRouter Router() =>
|
||||
new(new AudioProcessor(), new Mp3AudioProcessor(), new FlacAudioProcessor());
|
||||
|
||||
private static TrackContentService Content(FileDb db) => new(db, Router());
|
||||
|
||||
// -- End-to-end store byte-identity (passthrough) -----------------------------------------
|
||||
|
||||
[Test]
|
||||
public async Task StandardPcmWav_StoredByteIdenticalToSource()
|
||||
{
|
||||
var source = BuildPcmWav(channels: 2, sampleRate: 44100, bitsPerSample: 16, dataBytes: 200_000);
|
||||
var path = await WriteAsync(source, ".wav");
|
||||
var db = await FileDb.FromAsync(_testDir);
|
||||
|
||||
var content = Content(db!);
|
||||
var entity = await content.AddTrackAsync(path, "Track", "Artist");
|
||||
Assert.That(entity, Is.Not.Null);
|
||||
|
||||
var stored = await content.GetAudioBinaryAsync(entity!.EntryKey);
|
||||
Assert.That(stored, Is.Not.Null);
|
||||
Assert.That(stored!.Buffer, Is.EqualTo(source), "Standard PCM must be stored byte-identical");
|
||||
Assert.That(stored.Duration, Is.GreaterThan(0.0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Mp3_StoredByteIdenticalToSource_MetadataCorrect()
|
||||
{
|
||||
var source = BuildMinimalMp3();
|
||||
var path = await WriteAsync(source, ".mp3");
|
||||
var db = await FileDb.FromAsync(_testDir);
|
||||
|
||||
var content = Content(db!);
|
||||
var entity = await content.AddTrackAsync(path, "Track", "Artist");
|
||||
Assert.That(entity, Is.Not.Null);
|
||||
|
||||
var stored = await content.GetAudioBinaryAsync(entity!.EntryKey);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(stored, Is.Not.Null);
|
||||
Assert.That(stored!.Extension, Is.EqualTo(".mp3"));
|
||||
Assert.That(stored.Buffer, Is.EqualTo(source), "MP3 must be stored byte-identical (no transcode)");
|
||||
Assert.That(stored.Bitrate, Is.EqualTo(128));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Flac_StoredByteIdenticalToSource_MetadataCorrect()
|
||||
{
|
||||
var source = BuildMinimalFlac();
|
||||
var path = await WriteAsync(source, ".flac");
|
||||
var db = await FileDb.FromAsync(_testDir);
|
||||
|
||||
var content = Content(db!);
|
||||
var entity = await content.AddTrackAsync(path, "Track", "Artist");
|
||||
Assert.That(entity, Is.Not.Null);
|
||||
|
||||
var stored = await content.GetAudioBinaryAsync(entity!.EntryKey);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(stored, Is.Not.Null);
|
||||
Assert.That(stored!.Extension, Is.EqualTo(".flac"));
|
||||
Assert.That(stored.Buffer, Is.EqualTo(source), "FLAC must be stored byte-identical (no transcode)");
|
||||
Assert.That(stored.Duration, Is.GreaterThan(0.0));
|
||||
});
|
||||
}
|
||||
|
||||
// -- Streamed normalization ---------------------------------------------------------------
|
||||
|
||||
[Test]
|
||||
public async Task ExtensibleFloatWav_StoredAsNormalizedStandardPcm()
|
||||
{
|
||||
// A >80 KB float data region forces the streamed transform across multiple bounded chunks.
|
||||
var floatData = BuildFloatRamp(sampleCount: 40_000); // 160 000 bytes in, 120 000 bytes out (24-bit)
|
||||
var source = BuildExtensibleWav(channels: 2, sampleRate: 44100, containerBits: 32, validBits: 32,
|
||||
subFormatTag: 0x0003, sampleData: floatData);
|
||||
var path = await WriteAsync(source, ".wav");
|
||||
var db = await FileDb.FromAsync(_testDir);
|
||||
|
||||
var content = Content(db!);
|
||||
var entity = await content.AddTrackAsync(path, "Track", "Artist");
|
||||
Assert.That(entity, Is.Not.Null);
|
||||
|
||||
var stored = await content.GetAudioBinaryAsync(entity!.EntryKey);
|
||||
Assert.That(stored, Is.Not.Null);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(BitConverter.ToUInt16(stored!.Buffer, 20), Is.EqualTo(WaveFormatPcm),
|
||||
"EXTENSIBLE must be normalized to standard PCM (audioFormat = 1)");
|
||||
Assert.That(BitConverter.ToUInt16(stored.Buffer, 34), Is.EqualTo(24), "Float must normalize to 24-bit");
|
||||
Assert.That(stored.Buffer.Length, Is.EqualTo(44 + (floatData.Length / 4) * 3),
|
||||
"Output size = 44-byte header + 3 bytes per float sample");
|
||||
});
|
||||
}
|
||||
|
||||
// -- Streaming vault register round-trip ---------------------------------------------------
|
||||
|
||||
[Test]
|
||||
public async Task RegisterResourceStreamingAsync_RoundTrips()
|
||||
{
|
||||
var db = await FileDb.FromAsync(_testDir);
|
||||
await db!.CreateVaultAsync(VaultConstants.Tracks, MediaVaultType.Audio);
|
||||
|
||||
var payload = Enumerable.Range(0, 50_000).Select(i => (byte)(i % 256)).ToArray();
|
||||
var meta = MetaDataFactory.CreateAudioMetaData("entry-1", ".wav", 12.5, 1411);
|
||||
|
||||
var ok = await db.RegisterResourceStreamingAsync(
|
||||
VaultConstants.Tracks, "entry-1", meta,
|
||||
(dest, ct) => dest.WriteAsync(payload, ct).AsTask());
|
||||
Assert.That(ok, Is.True);
|
||||
|
||||
var loaded = await db.LoadResourceAsync<AudioBinary>(VaultConstants.Tracks, "entry-1");
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(loaded, Is.Not.Null);
|
||||
Assert.That(loaded!.Buffer, Is.EqualTo(payload), "Streamed bytes must round-trip exactly");
|
||||
Assert.That(loaded.Duration, Is.EqualTo(12.5));
|
||||
Assert.That(loaded.Bitrate, Is.EqualTo(1411));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RegisterResourceStreamingAsync_UnknownVault_ReturnsFalse()
|
||||
{
|
||||
var db = await FileDb.FromAsync(_testDir);
|
||||
var meta = MetaDataFactory.CreateAudioMetaData("e", ".wav", 1.0, 1411);
|
||||
|
||||
var ok = await db!.RegisterResourceStreamingAsync(
|
||||
"does-not-exist", "e", meta, (dest, ct) => Task.CompletedTask);
|
||||
|
||||
Assert.That(ok, Is.False, "Register into a missing vault must swallow and return false");
|
||||
}
|
||||
|
||||
// -- Memory-bounding of the store primitive -----------------------------------------------
|
||||
|
||||
[Test]
|
||||
public async Task WriteToAsync_StreamsInBoundedSequentialChunks_NotOneWholeFileWrite()
|
||||
{
|
||||
// A multi-hundred-KB passthrough file: a buffered implementation would issue one giant write of
|
||||
// the whole body; the streamed primitive must write in bounded, forward-only chunks.
|
||||
var source = BuildPcmWav(channels: 2, sampleRate: 44100, bitsPerSample: 16, dataBytes: 600_000);
|
||||
var path = await WriteAsync(source, ".wav");
|
||||
|
||||
var processed = await new AudioProcessor().ProcessWavFileAsync(path);
|
||||
Assert.That(processed, Is.Not.Null);
|
||||
|
||||
var probe = new BoundedWriteProbeStream();
|
||||
await processed!.WriteToAsync(probe);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(probe.TotalBytes, Is.EqualTo(source.Length), "All bytes must be written");
|
||||
Assert.That(probe.WriteCount, Is.GreaterThan(1), "Body must be streamed in multiple chunks");
|
||||
Assert.That(probe.MaxWriteSize, Is.LessThanOrEqualTo(81920),
|
||||
"No single write may exceed the bounded buffer — i.e. the whole file is never buffered");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WriteToAsync_NormalizedFloat_StreamsToForwardOnlyStream()
|
||||
{
|
||||
// The normalized path seeks the *source* but must only write the destination sequentially.
|
||||
var floatData = BuildFloatRamp(sampleCount: 30_000);
|
||||
var source = BuildExtensibleWav(channels: 2, sampleRate: 44100, containerBits: 32, validBits: 32,
|
||||
subFormatTag: 0x0003, sampleData: floatData);
|
||||
var path = await WriteAsync(source, ".wav");
|
||||
|
||||
var processed = await new AudioProcessor().ProcessWavFileAsync(path);
|
||||
Assert.That(processed, Is.Not.Null);
|
||||
|
||||
var probe = new BoundedWriteProbeStream();
|
||||
await processed!.WriteToAsync(probe);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(probe.TotalBytes, Is.EqualTo(44 + (floatData.Length / 4) * 3));
|
||||
Assert.That(probe.WriteCount, Is.GreaterThan(1), "Header + multiple transformed body chunks");
|
||||
Assert.That(probe.MaxWriteSize, Is.LessThanOrEqualTo(81920));
|
||||
});
|
||||
}
|
||||
|
||||
// -- builders -----------------------------------------------------------------------------
|
||||
|
||||
private async Task<string> WriteAsync(byte[] bytes, string extension)
|
||||
{
|
||||
var path = Path.Combine(_testDir, Guid.NewGuid().ToString("N") + extension);
|
||||
await File.WriteAllBytesAsync(path, bytes);
|
||||
return path;
|
||||
}
|
||||
|
||||
private static byte[] BuildPcmWav(int channels, int sampleRate, int bitsPerSample, int dataBytes)
|
||||
{
|
||||
var blockAlign = (ushort)(channels * (bitsPerSample / 8));
|
||||
var byteRate = (uint)(sampleRate * blockAlign);
|
||||
var data = new byte[dataBytes];
|
||||
for (var i = 0; i < data.Length; i++) data[i] = (byte)(i % 251);
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
using var w = new BinaryWriter(ms, Encoding.ASCII, leaveOpen: true);
|
||||
w.Write(Encoding.ASCII.GetBytes("RIFF"));
|
||||
w.Write((uint)(36 + data.Length));
|
||||
w.Write(Encoding.ASCII.GetBytes("WAVE"));
|
||||
w.Write(Encoding.ASCII.GetBytes("fmt "));
|
||||
w.Write(16u);
|
||||
w.Write(WaveFormatPcm);
|
||||
w.Write((ushort)channels);
|
||||
w.Write((uint)sampleRate);
|
||||
w.Write(byteRate);
|
||||
w.Write(blockAlign);
|
||||
w.Write((ushort)bitsPerSample);
|
||||
w.Write(Encoding.ASCII.GetBytes("data"));
|
||||
w.Write((uint)data.Length);
|
||||
w.Write(data);
|
||||
w.Flush();
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
private static byte[] BuildExtensibleWav(
|
||||
int channels, int sampleRate, int containerBits, int validBits, ushort subFormatTag, byte[] sampleData)
|
||||
{
|
||||
var blockAlign = (ushort)(channels * (containerBits / 8));
|
||||
var byteRate = (uint)(sampleRate * blockAlign);
|
||||
const uint fmtChunkSize = 40;
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
using var w = new BinaryWriter(ms, Encoding.ASCII, leaveOpen: true);
|
||||
w.Write(Encoding.ASCII.GetBytes("RIFF"));
|
||||
w.Write((uint)(36 + (fmtChunkSize - 16) + sampleData.Length));
|
||||
w.Write(Encoding.ASCII.GetBytes("WAVE"));
|
||||
w.Write(Encoding.ASCII.GetBytes("fmt "));
|
||||
w.Write(fmtChunkSize);
|
||||
w.Write(WaveFormatExtensible);
|
||||
w.Write((ushort)channels);
|
||||
w.Write((uint)sampleRate);
|
||||
w.Write(byteRate);
|
||||
w.Write(blockAlign);
|
||||
w.Write((ushort)containerBits);
|
||||
w.Write((ushort)22); // cbSize
|
||||
w.Write((ushort)validBits); // wValidBitsPerSample
|
||||
w.Write((uint)0); // channel mask
|
||||
var guid = new byte[16];
|
||||
guid[0] = (byte)(subFormatTag & 0xFF);
|
||||
guid[1] = (byte)((subFormatTag >> 8) & 0xFF);
|
||||
w.Write(guid);
|
||||
w.Write(Encoding.ASCII.GetBytes("data"));
|
||||
w.Write((uint)sampleData.Length);
|
||||
w.Write(sampleData);
|
||||
w.Flush();
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
private static byte[] BuildFloatRamp(int sampleCount)
|
||||
{
|
||||
var bytes = new byte[sampleCount * 4];
|
||||
for (var i = 0; i < sampleCount; i++)
|
||||
{
|
||||
var sample = (float)((i % 200) / 200.0 - 0.5); // a deterministic ramp in [-0.5, 0.5)
|
||||
BitConverter.GetBytes(sample).CopyTo(bytes, i * 4);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static byte[] BuildMinimalMp3()
|
||||
{
|
||||
// One MPEG1 Layer III CBR frame: 128 kbps, 44.1 kHz, stereo. Body zero-filled (silence).
|
||||
const int frameSize = 417; // floor(144 * 128000 / 44100)
|
||||
var buffer = new byte[frameSize];
|
||||
buffer[0] = 0xFF;
|
||||
buffer[1] = 0xFB; // sync + MPEG1 + Layer III + no CRC
|
||||
buffer[2] = (byte)((9 << 4) | (0 << 2)); // bitrate index 9 (128 kbps), sample-rate index 0 (44.1 kHz)
|
||||
buffer[3] = 0x00; // stereo
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private static byte[] BuildMinimalFlac()
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
ms.Write(Encoding.ASCII.GetBytes("fLaC"));
|
||||
ms.WriteByte(0x80); // last block, STREAMINFO
|
||||
ms.WriteByte(0x00);
|
||||
ms.WriteByte(0x00);
|
||||
ms.WriteByte(34);
|
||||
|
||||
var s = new byte[34];
|
||||
const int sampleRate = 44100;
|
||||
const int channels = 2;
|
||||
const int bitsPerSample = 16;
|
||||
const long totalSamples = 44100L * 5;
|
||||
s[10] = (byte)((sampleRate >> 12) & 0xFF);
|
||||
s[11] = (byte)((sampleRate >> 4) & 0xFF);
|
||||
var bps = bitsPerSample - 1;
|
||||
s[12] = (byte)(((sampleRate & 0x0F) << 4) | (((channels - 1) & 0x07) << 1) | ((bps >> 4) & 0x01));
|
||||
s[13] = (byte)(((bps & 0x0F) << 4) | (int)((totalSamples >> 32) & 0x0F));
|
||||
s[14] = (byte)((totalSamples >> 24) & 0xFF);
|
||||
s[15] = (byte)((totalSamples >> 16) & 0xFF);
|
||||
s[16] = (byte)((totalSamples >> 8) & 0xFF);
|
||||
s[17] = (byte)(totalSamples & 0xFF);
|
||||
ms.Write(s);
|
||||
|
||||
// Trailing zero bytes standing in for encoded frames (affect only the average-bitrate compute).
|
||||
ms.Write(new byte[100_000]);
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A write-only, forward-only (non-seekable) stream that records how the store primitive writes:
|
||||
/// the number of writes, the largest single write, and the total. Proves the body is streamed in
|
||||
/// bounded, sequential chunks rather than buffered into one whole-file write.
|
||||
/// </summary>
|
||||
private sealed class BoundedWriteProbeStream : Stream
|
||||
{
|
||||
public int WriteCount { get; private set; }
|
||||
public int MaxWriteSize { get; private set; }
|
||||
public long TotalBytes { get; private set; }
|
||||
|
||||
public override bool CanRead => false;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => true;
|
||||
public override long Length => TotalBytes;
|
||||
public override long Position { get => TotalBytes; set => throw new NotSupportedException(); }
|
||||
|
||||
private void Record(int count)
|
||||
{
|
||||
WriteCount++;
|
||||
if (count > MaxWriteSize) MaxWriteSize = count;
|
||||
TotalBytes += count;
|
||||
}
|
||||
|
||||
public override void Write(byte[] buffer, int offset, int count) => Record(count);
|
||||
|
||||
public override void Write(ReadOnlySpan<byte> buffer) => Record(buffer.Length);
|
||||
|
||||
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Record(buffer.Length);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
|
||||
{
|
||||
Record(count);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override void Flush() { }
|
||||
public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -68,9 +68,9 @@ public class TrackReplaceAudioTests
|
||||
var originalDuration = before!.Duration;
|
||||
|
||||
var replacement = await WriteWavAsync(BuildMinimalPcmWav(6.0), ".wav");
|
||||
var newAudio = await content.ReplaceTrackAudioAsync(entryKey, replacement);
|
||||
var newDuration = await content.ReplaceTrackAudioAsync(entryKey, replacement);
|
||||
|
||||
Assert.That(newAudio, Is.Not.Null, "Replace should return the freshly stored audio");
|
||||
Assert.That(newDuration, Is.Not.Null, "Replace should return the freshly stored audio's duration");
|
||||
|
||||
var after = await content.GetAudioBinaryAsync(entryKey);
|
||||
Assert.Multiple(() =>
|
||||
@@ -78,8 +78,8 @@ public class TrackReplaceAudioTests
|
||||
Assert.That(after, Is.Not.Null, "The track must remain retrievable under the same EntryKey");
|
||||
Assert.That(after!.Duration, Is.GreaterThan(originalDuration),
|
||||
"The retrieved audio must reflect the longer replacement, not the original");
|
||||
Assert.That(newAudio!.Duration, Is.EqualTo(after.Duration),
|
||||
"The returned binary must match what is stored under the key");
|
||||
Assert.That(newDuration!.Value, Is.EqualTo(after.Duration),
|
||||
"The returned duration must match what is stored under the key");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -101,9 +101,9 @@ public class TrackReplaceAudioTests
|
||||
Assert.That(wavFilesBefore, Is.Not.Empty, "Sanity: the original .wav backing file exists");
|
||||
|
||||
var replacement = await WriteFlacAsync();
|
||||
var newAudio = await content.ReplaceTrackAudioAsync(entryKey, replacement);
|
||||
var newDuration = await content.ReplaceTrackAudioAsync(entryKey, replacement);
|
||||
|
||||
Assert.That(newAudio, Is.Not.Null);
|
||||
Assert.That(newDuration, Is.Not.Null);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(Directory.GetFiles(vaultDir, "*.wav"), Is.Empty,
|
||||
@@ -135,12 +135,15 @@ public class TrackReplaceAudioTests
|
||||
Assert.That(staleHighRes, Is.Not.Null);
|
||||
|
||||
var replacement = await WriteWavAsync(BuildMinimalPcmWav(20.0), ".wav");
|
||||
var newAudio = await content.ReplaceTrackAudioAsync(entryKey, replacement);
|
||||
Assert.That(newAudio, Is.Not.Null);
|
||||
var newDuration = await content.ReplaceTrackAudioAsync(entryKey, replacement);
|
||||
Assert.That(newDuration, Is.Not.Null);
|
||||
|
||||
// Regen step (mirrors the orchestrator).
|
||||
Assert.That(await waveforms.ComputeAndStoreAsync(newAudio!.Buffer, entryKey), Is.True);
|
||||
Assert.That(await waveforms.ComputeAndStoreHighResAsync(newAudio.Buffer, entryKey, newAudio.Duration), Is.True);
|
||||
// Regen step (mirrors the orchestrator, which re-reads the freshly stored audio from the vault
|
||||
// rather than consuming an in-memory buffer the streamed store no longer hands back).
|
||||
var newStored = await content.GetAudioBinaryAsync(entryKey);
|
||||
Assert.That(newStored, Is.Not.Null);
|
||||
Assert.That(await waveforms.ComputeAndStoreAsync(newStored!.Buffer, entryKey), Is.True);
|
||||
Assert.That(await waveforms.ComputeAndStoreHighResAsync(newStored.Buffer, entryKey, newStored.Duration), Is.True);
|
||||
|
||||
var freshHighRes = await waveforms.GetProfileAsync(entryKey, VaultConstants.TrackWaveforms);
|
||||
var freshProfile = await waveforms.GetProfileAsync(entryKey);
|
||||
|
||||
Reference in New Issue
Block a user