Files
deepdrft/DeepDrftTests/WaveformProfileServiceTests.cs
daniel-c-harvey accf20ba57 feat(waveform): generalize high-res compute to every track (Direction B)
Per-track high-res datum keyed by EntryKey in the renamed track-waveforms vault; computed at upload for all tracks, regenerable per-track via CMS, with a re-runnable backfill. Mix read path repointed so it keeps working.
2026-06-17 10:18:44 -04:00

185 lines
8.1 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
/// Integration tests for the per-track high-res waveform compute (phase-12 §5, Direction B). These
/// exercise the exact content-side path the upload, CMS generate action, and Mix trigger all funnel
/// through (<see cref="WaveformProfileService.ComputeAndStoreHighResAsync"/>) over a real
/// <see cref="FileDb"/> and a real <see cref="AudioProcessor"/> + <see cref="RmsLoudnessAlgorithm"/>.
/// The track's medium is irrelevant here — that is the point of the generalization: the content
/// service computes a datum from any track's audio, keyed by EntryKey, with no Mix coupling.
/// </summary>
[TestFixture]
public class WaveformProfileServiceTests
{
private string _testDir = string.Empty;
[SetUp]
public void SetUp()
{
_testDir = Path.Combine(Path.GetTempPath(), "WaveformProfileServiceTests", Guid.NewGuid().ToString());
Directory.CreateDirectory(_testDir);
}
[TearDown]
public void TearDown()
{
try { Directory.Delete(_testDir, recursive: true); }
catch { /* Best-effort cleanup — ignore failures */ }
}
private async Task<WaveformProfileService> CreateServiceAsync(FileDb fileDatabase)
{
await Task.CompletedTask;
return new WaveformProfileService(
fileDatabase,
new AudioProcessor(),
new RmsLoudnessAlgorithm(),
Options.Create(new WaveformProfileOptions()),
NullLogger<WaveformProfileService>.Instance);
}
[Test]
public async Task ComputeAndStoreHighResAsync_NonMixTrack_StoresDatumInTrackWaveformsVault()
{
var fileDatabase = await FileDb.FromAsync(_testDir);
Assert.That(fileDatabase, Is.Not.Null);
var service = await CreateServiceAsync(fileDatabase!);
// A 2-second mono 16-bit WAV — stands in for "any track" (Cut/Session/Mix alike). No release
// or medium is involved; the compute is keyed only by the supplied EntryKey.
const string entryKey = "cut-track-entry";
var wav = BuildMinimalPcmWav(durationSeconds: 2.0);
var stored = await service.ComputeAndStoreHighResAsync(wav, entryKey, durationSeconds: 2.0);
Assert.That(stored, Is.True, "High-res compute should succeed for a decodable PCM WAV");
var datum = await service.GetProfileAsync(entryKey, VaultConstants.TrackWaveforms);
Assert.That(datum, Is.Not.Null, "Datum must be retrievable from the track-waveforms vault by EntryKey");
// 2 s × 333/s = 666 buckets, below the floor → clamps to the floor (2048).
Assert.That(datum!.Length, Is.EqualTo(WaveformResolution.BucketCountForDuration(2.0)));
Assert.That(datum.Length, Is.EqualTo(WaveformResolution.MinBucketCount));
}
[Test]
public async Task ComputeAndStoreHighResAsync_LongTrack_BucketCountIsDurationDerived()
{
var fileDatabase = await FileDb.FromAsync(_testDir);
var service = await CreateServiceAsync(fileDatabase!);
// A 10-second WAV: 10 × 333 = 3330 buckets, above the 2048 floor — proves the count tracks
// duration rather than the fixed 512-bucket profile resolution.
const string entryKey = "long-track-entry";
var wav = BuildMinimalPcmWav(durationSeconds: 10.0);
var stored = await service.ComputeAndStoreHighResAsync(wav, entryKey, durationSeconds: 10.0);
Assert.That(stored, Is.True);
var datum = await service.GetProfileAsync(entryKey, VaultConstants.TrackWaveforms);
Assert.That(datum, Is.Not.Null);
Assert.That(datum!.Length, Is.EqualTo(WaveformResolution.BucketCountForDuration(10.0)));
Assert.That(datum.Length, Is.GreaterThan(new WaveformProfileOptions().BucketCount),
"The high-res datum must be denser than the fixed 512-bucket player-bar profile");
}
[Test]
public async Task HighResAndProfile_ForSameTrack_StoredInSeparateVaultsKeyedByEntryKey()
{
var fileDatabase = await FileDb.FromAsync(_testDir);
var service = await CreateServiceAsync(fileDatabase!);
// The two datums a track carries (phase-12 §5): the 512-bucket player-bar profile and the
// duration-derived high-res visualizer datum. Both key off the same EntryKey but live in
// distinct vaults, so neither overwrites the other.
const string entryKey = "shared-key";
var wav = BuildMinimalPcmWav(durationSeconds: 10.0);
Assert.That(await service.ComputeAndStoreAsync(wav, entryKey), Is.True);
Assert.That(await service.ComputeAndStoreHighResAsync(wav, entryKey, durationSeconds: 10.0), Is.True);
var profile = await service.GetProfileAsync(entryKey);
var highRes = await service.GetProfileAsync(entryKey, VaultConstants.TrackWaveforms);
Assert.Multiple(() =>
{
Assert.That(profile, Is.Not.Null);
Assert.That(highRes, Is.Not.Null);
Assert.That(profile!.Length, Is.EqualTo(new WaveformProfileOptions().BucketCount));
Assert.That(highRes!.Length, Is.EqualTo(WaveformResolution.BucketCountForDuration(10.0)));
Assert.That(highRes.Length, Is.Not.EqualTo(profile.Length), "The two datums must differ in resolution");
});
}
[Test]
public async Task ComputeAndStoreHighResAsync_IsRerunnable_OverwritesPriorDatum()
{
var fileDatabase = await FileDb.FromAsync(_testDir);
var service = await CreateServiceAsync(fileDatabase!);
// The backfill / regenerate path must be re-runnable: a second compute for the same key
// overwrites cleanly rather than failing or duplicating.
const string entryKey = "rerun-key";
var wav = BuildMinimalPcmWav(durationSeconds: 10.0);
Assert.That(await service.ComputeAndStoreHighResAsync(wav, entryKey, durationSeconds: 10.0), Is.True);
Assert.That(await service.ComputeAndStoreHighResAsync(wav, entryKey, durationSeconds: 10.0), Is.True,
"A re-run must succeed and overwrite the prior datum");
var datum = await service.GetProfileAsync(entryKey, VaultConstants.TrackWaveforms);
Assert.That(datum, Is.Not.Null);
Assert.That(datum!.Length, Is.EqualTo(WaveformResolution.BucketCountForDuration(10.0)));
}
// Builds a minimal standard-PCM mono 16-bit 44.1 kHz WAV with a full-scale square wave across the
// requested duration. Real PCM (not silence) so the loudness algorithm produces a non-degenerate
// envelope. Mirrors the chunk layout AudioProcessor expects (RIFF/WAVE/fmt /data).
private static byte[] BuildMinimalPcmWav(double durationSeconds)
{
const int sampleRate = 44100;
const ushort channels = 1;
const ushort bitsPerSample = 16;
const ushort blockAlign = channels * (bitsPerSample / 8);
const uint byteRate = sampleRate * blockAlign;
var frames = (int)(sampleRate * durationSeconds);
var data = new byte[frames * blockAlign];
for (var i = 0; i < frames; i++)
{
// Alternating full-scale square wave so RMS reads as loud, not silent.
var sample = (i % 2 == 0) ? short.MaxValue : short.MinValue;
data[i * 2] = (byte)(sample & 0xFF);
data[i * 2 + 1] = (byte)((sample >> 8) & 0xFF);
}
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((ushort)1); // PCM
w.Write(channels);
w.Write((uint)sampleRate);
w.Write(byteRate);
w.Write(blockAlign);
w.Write(bitsPerSample);
w.Write(Encoding.ASCII.GetBytes("data"));
w.Write((uint)data.Length);
w.Write(data);
w.Flush();
return ms.ToArray();
}
}