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(); } }