339 lines
14 KiB
C#
339 lines
14 KiB
C#
using System.Text;
|
|
using DeepDrftContent.Constants;
|
|
using DeepDrftContent.Processors;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
|
|
|
|
namespace DeepDrftTests;
|
|
|
|
/// <summary>
|
|
/// Wave 2 parity tests for the streaming waveform compute. The single most important property: the
|
|
/// streaming path (bounded reads, never the whole file in a managed byte[]) must store bytes IDENTICAL
|
|
/// to the prior whole-buffer path for the same WAV — for both the 512-bucket profile and the
|
|
/// duration-derived high-res datum. These tests compute the profile both ways over the same WAV and
|
|
/// assert byte-equality, then cover the bounded-memory guarantee, the mp3/flac graceful-null, and the
|
|
/// sample-alignment edges (fewer samples than buckets; a data region whose length is not a whole
|
|
/// multiple of the frame size).
|
|
/// </summary>
|
|
[TestFixture]
|
|
public class WaveformStreamingParityTests
|
|
{
|
|
private string _testDir = string.Empty;
|
|
|
|
[SetUp]
|
|
public void SetUp()
|
|
{
|
|
_testDir = Path.Combine(Path.GetTempPath(), "WaveformStreamingParityTests", Guid.NewGuid().ToString());
|
|
Directory.CreateDirectory(_testDir);
|
|
}
|
|
|
|
[TearDown]
|
|
public void TearDown()
|
|
{
|
|
try { Directory.Delete(_testDir, recursive: true); }
|
|
catch { /* Best-effort cleanup — ignore failures */ }
|
|
}
|
|
|
|
private static WaveformProfileService CreateService(FileDb fileDatabase) =>
|
|
new(
|
|
fileDatabase,
|
|
new AudioProcessor(),
|
|
new RmsLoudnessAlgorithm(),
|
|
Options.Create(new WaveformProfileOptions()),
|
|
NullLogger<WaveformProfileService>.Instance);
|
|
|
|
// ----- Parity: 512-bucket profile -----
|
|
|
|
[Test]
|
|
public async Task ProfileStreaming_16BitStereo_ByteIdenticalToWholeBuffer()
|
|
{
|
|
var db = await FileDb.FromAsync(_testDir);
|
|
var service = CreateService(db!);
|
|
var wav = BuildPcmWav(sampleRate: 44100, channels: 2, bitsPerSample: 16, frames: 50_000);
|
|
|
|
var (reference, streaming) = await ComputeProfileBothWaysAsync(service, wav);
|
|
|
|
Assert.That(streaming, Is.Not.Null);
|
|
Assert.That(streaming, Is.EqualTo(reference).AsCollection,
|
|
"Streaming 512-bucket profile must be byte-identical to the whole-buffer computation");
|
|
}
|
|
|
|
[Test]
|
|
public async Task ProfileStreaming_24BitStereo_NormalizedShape_ByteIdenticalToWholeBuffer()
|
|
{
|
|
// 24-bit standard PCM is exactly what the store path emits when it normalizes a 24-in-32 / float
|
|
// EXTENSIBLE source. bytesPerFrame = 6 exercises odd alignment across the bounded read boundary.
|
|
var db = await FileDb.FromAsync(_testDir);
|
|
var service = CreateService(db!);
|
|
var wav = BuildPcmWav(sampleRate: 48000, channels: 2, bitsPerSample: 24, frames: 40_000);
|
|
|
|
var (reference, streaming) = await ComputeProfileBothWaysAsync(service, wav);
|
|
|
|
Assert.That(streaming, Is.EqualTo(reference).AsCollection,
|
|
"Streaming profile for a 24-bit normalized-shape WAV must be byte-identical");
|
|
}
|
|
|
|
// ----- Parity: high-res datum -----
|
|
|
|
[Test]
|
|
public async Task HighResStreaming_16BitStereo_ByteIdenticalToWholeBuffer()
|
|
{
|
|
var db = await FileDb.FromAsync(_testDir);
|
|
var service = CreateService(db!);
|
|
const double duration = 7.0; // > floor so the bucket count is genuinely duration-derived
|
|
var wav = BuildPcmWav(sampleRate: 44100, channels: 2, bitsPerSample: 16, frames: (int)(44100 * duration));
|
|
|
|
await service.ComputeAndStoreHighResAsync(wav, "ref", duration);
|
|
var reference = await service.GetProfileAsync("ref", VaultConstants.TrackWaveforms);
|
|
|
|
var stored = await service.ComputeAndStoreHighResStreamingAsync(
|
|
_ => Task.FromResult<Stream?>(new MemoryStream(wav)), "stream", duration);
|
|
var streaming = await service.GetProfileAsync("stream", VaultConstants.TrackWaveforms);
|
|
|
|
Assert.That(stored, Is.True);
|
|
Assert.That(streaming, Is.EqualTo(reference).AsCollection,
|
|
"Streaming high-res datum must be byte-identical to the whole-buffer computation");
|
|
}
|
|
|
|
[Test]
|
|
public async Task AllStreaming_HotPath_BothDatumsByteIdenticalToWholeBuffer()
|
|
{
|
|
// The upload/replace hot path computes BOTH datums in one streaming pass. Each must match its
|
|
// whole-buffer counterpart exactly.
|
|
var db = await FileDb.FromAsync(_testDir);
|
|
var service = CreateService(db!);
|
|
const double duration = 5.0;
|
|
var wav = BuildPcmWav(sampleRate: 44100, channels: 2, bitsPerSample: 16, frames: (int)(44100 * duration));
|
|
|
|
await service.ComputeAndStoreAsync(wav, "ref");
|
|
await service.ComputeAndStoreHighResAsync(wav, "ref", duration);
|
|
var refProfile = await service.GetProfileAsync("ref");
|
|
var refHighRes = await service.GetProfileAsync("ref", VaultConstants.TrackWaveforms);
|
|
|
|
var stored = await service.ComputeAndStoreAllStreamingAsync(
|
|
_ => Task.FromResult<Stream?>(new MemoryStream(wav)), "stream", duration);
|
|
var streamProfile = await service.GetProfileAsync("stream");
|
|
var streamHighRes = await service.GetProfileAsync("stream", VaultConstants.TrackWaveforms);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(stored, Is.True);
|
|
Assert.That(streamProfile, Is.EqualTo(refProfile).AsCollection, "512-bucket profile parity");
|
|
Assert.That(streamHighRes, Is.EqualTo(refHighRes).AsCollection, "high-res datum parity");
|
|
});
|
|
}
|
|
|
|
// ----- Bounded memory -----
|
|
|
|
[Test]
|
|
public async Task ProfileStreaming_LargeWav_ReadsInBoundedChunks_NeverWholeFile()
|
|
{
|
|
var db = await FileDb.FromAsync(_testDir);
|
|
var service = CreateService(db!);
|
|
// ~1.4 MB of PCM — well past any single bounded read, so the consumer is forced to chunk.
|
|
var wav = BuildPcmWav(sampleRate: 44100, channels: 2, bitsPerSample: 16, frames: 180_000);
|
|
|
|
var counter = new MaxReadTrackingStream(wav);
|
|
var stored = await service.ComputeAndStoreProfileStreamingAsync(_ => Task.FromResult<Stream?>(counter), "big");
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(stored, Is.True, "A large, only-chunk-readable WAV must compute successfully");
|
|
Assert.That(counter.MaxSingleRead, Is.LessThanOrEqualTo(81920),
|
|
"No single read may request the whole file — peak buffer is bounded, not O(filesize)");
|
|
Assert.That(counter.ReadCallCount, Is.GreaterThan(1),
|
|
"A file larger than one buffer must be drained across multiple bounded reads");
|
|
});
|
|
}
|
|
|
|
// ----- mp3 / flac graceful-null -----
|
|
|
|
[Test]
|
|
public async Task ProfileStreaming_NonWavBytes_WritesNoProfile_ReturnsFalse()
|
|
{
|
|
var db = await FileDb.FromAsync(_testDir);
|
|
var service = CreateService(db!);
|
|
// Stand-in for mp3/flac: present audio bytes that are not a RIFF/WAVE container.
|
|
var notWav = Encoding.ASCII.GetBytes("ID3 |