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;
///
/// 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 () over a real
/// and a real + .
/// 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.
///
[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 CreateServiceAsync(FileDb fileDatabase)
{
await Task.CompletedTask;
return new WaveformProfileService(
fileDatabase,
new AudioProcessor(),
new RmsLoudnessAlgorithm(),
Options.Create(new WaveformProfileOptions()),
NullLogger.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();
}
}