using System.Text; using DeepDrftContent; using DeepDrftContent.Constants; using DeepDrftContent.FileDatabase.Models; using DeepDrftContent.Processors; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase; namespace DeepDrftTests; /// /// Integration tests for the content-side audio-replace seam /// () and the waveform regeneration that the /// API orchestrator runs after it. These exercise the real , the real /// , and the real over /// temp-directory-isolated vaults — the same pattern as . /// /// The replace contract under test: the vault key (EntryKey) is preserved, only the bytes change, /// no stale backing file is left behind on a cross-format swap, and the waveform datums are /// re-computed against the new audio. SQL-side preservation (track id, release link, position, /// metadata) is guaranteed structurally — the orchestrator never writes SQL on replace — so it is /// documented here rather than asserted against a database this suite does not stand up. /// [TestFixture] public class TrackReplaceAudioTests { private string _testDir = string.Empty; [SetUp] public void SetUp() { _testDir = Path.Combine(Path.GetTempPath(), "TrackReplaceAudioTests", Guid.NewGuid().ToString()); Directory.CreateDirectory(_testDir); } [TearDown] public void TearDown() { try { Directory.Delete(_testDir, recursive: true); } catch { /* Best-effort cleanup — ignore failures */ } } private static TrackContentService CreateContentService(FileDb fileDatabase) => new(fileDatabase, new AudioProcessorRouter( new AudioProcessor(), new Mp3AudioProcessor(), new FlacAudioProcessor())); private static WaveformProfileService CreateWaveformService(FileDb fileDatabase) => new(fileDatabase, new AudioProcessor(), new RmsLoudnessAlgorithm(), Options.Create(new WaveformProfileOptions()), NullLogger.Instance); [Test] public async Task ReplaceTrackAudioAsync_SwapsBytes_PreservingEntryKey() { var fileDatabase = await FileDb.FromAsync(_testDir); Assert.That(fileDatabase, Is.Not.Null); var content = CreateContentService(fileDatabase!); // Seed a 2-second track, then replace it with a 6-second one. The entry key is the stable // SQL→vault link; replace must reuse it so the track row keeps pointing at live audio. var original = await WriteWavAsync(BuildMinimalPcmWav(2.0), ".wav"); var seeded = await content.AddTrackAsync(original, "Original", "Artist"); Assert.That(seeded, Is.Not.Null); var entryKey = seeded!.EntryKey; var before = await content.GetAudioBinaryAsync(entryKey); Assert.That(before, Is.Not.Null); var originalDuration = before!.Duration; var replacement = await WriteWavAsync(BuildMinimalPcmWav(6.0), ".wav"); var newAudio = await content.ReplaceTrackAudioAsync(entryKey, replacement); Assert.That(newAudio, Is.Not.Null, "Replace should return the freshly stored audio"); var after = await content.GetAudioBinaryAsync(entryKey); Assert.Multiple(() => { 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"); }); } [Test] public async Task ReplaceTrackAudioAsync_CrossFormat_RemovesStaleBackingFile() { var fileDatabase = await FileDb.FromAsync(_testDir); var content = CreateContentService(fileDatabase!); // A .wav original replaced by a .flac: the backing filename is keyed by extension, so a // register-only swap would strand the old .wav. The replace writes the new entry first, then // cleans up the stale old backing file (detected by comparing old vs. new extension). var original = await WriteWavAsync(BuildMinimalPcmWav(2.0), ".wav"); var seeded = await content.AddTrackAsync(original, "Original", "Artist"); var entryKey = seeded!.EntryKey; var vaultDir = Path.Combine(_testDir, VaultConstants.Tracks); var wavFilesBefore = Directory.GetFiles(vaultDir, "*.wav"); Assert.That(wavFilesBefore, Is.Not.Empty, "Sanity: the original .wav backing file exists"); var replacement = await WriteFlacAsync(); var newAudio = await content.ReplaceTrackAudioAsync(entryKey, replacement); Assert.That(newAudio, Is.Not.Null); Assert.Multiple(() => { Assert.That(Directory.GetFiles(vaultDir, "*.wav"), Is.Empty, "The stale .wav backing file must be removed on a cross-format replace"); Assert.That(Directory.GetFiles(vaultDir, "*.flac"), Is.Not.Empty, "The new .flac backing file must be present"); }); } [Test] public async Task ReplaceThenRegenerate_RewritesWaveformDatumsForNewAudio() { var fileDatabase = await FileDb.FromAsync(_testDir); var content = CreateContentService(fileDatabase!); var waveforms = CreateWaveformService(fileDatabase!); // Seed a short track and its waveform datums, then replace with a longer track and regenerate // (the exact sequence UnifiedTrackService.ReplaceAudioAsync runs). The high-res datum is // duration-derived, so a longer replacement yields a denser datum — proving the regen ran // against the new audio rather than leaving the stale datum in place. var original = await WriteWavAsync(BuildMinimalPcmWav(3.0), ".wav"); var seeded = await content.AddTrackAsync(original, "Original", "Artist"); var entryKey = seeded!.EntryKey; var seedAudio = await content.GetAudioBinaryAsync(entryKey); await waveforms.ComputeAndStoreAsync(seedAudio!.Buffer, entryKey); await waveforms.ComputeAndStoreHighResAsync(seedAudio.Buffer, entryKey, seedAudio.Duration); var staleHighRes = await waveforms.GetProfileAsync(entryKey, VaultConstants.TrackWaveforms); 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); // 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); var freshHighRes = await waveforms.GetProfileAsync(entryKey, VaultConstants.TrackWaveforms); var freshProfile = await waveforms.GetProfileAsync(entryKey); Assert.Multiple(() => { Assert.That(freshHighRes, Is.Not.Null); Assert.That(freshProfile, Is.Not.Null, "The 512-bucket profile must also be present after regen"); Assert.That(freshHighRes!.Length, Is.EqualTo(WaveformResolution.BucketCountForDuration(20.0)), "The high-res datum must track the new (longer) duration"); Assert.That(freshHighRes.Length, Is.Not.EqualTo(staleHighRes!.Length), "The regenerated datum must differ from the stale one keyed to the shorter original"); }); } // --- WAV / FLAC test fixtures --- private async Task WriteWavAsync(byte[] bytes, string extension) { var path = Path.Combine(_testDir, Guid.NewGuid().ToString("N") + extension); await File.WriteAllBytesAsync(path, bytes); return path; } // Minimal valid FLAC: 'fLaC' magic + a STREAMINFO metadata block. The processor reads STREAMINFO // for sample rate / channels / bits / total samples; it does not decode frames, so an empty audio // payload is sufficient to produce a non-null AudioBinary with a .flac extension. private async Task WriteFlacAsync() { var path = Path.Combine(_testDir, Guid.NewGuid().ToString("N") + ".flac"); await File.WriteAllBytesAsync(path, BuildMinimalFlac()); return path; } // Builds a standard-PCM mono 16-bit 44.1 kHz WAV of the requested duration with a full-scale // square wave (non-silent so the loudness algorithm yields a real envelope). Same layout as // WaveformProfileServiceTests.BuildMinimalPcmWav. 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++) { 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(); } // Builds the minimal FLAC the processor can parse: 'fLaC' + one STREAMINFO block (type 0, last). // STREAMINFO is 34 bytes; the processor reads the bit-packed 20-bit sample rate, 3-bit channels, // 5-bit bits-per-sample, and 36-bit total-samples fields. Values: 44.1 kHz, mono, 16-bit, some // total samples so duration > 0. private static byte[] BuildMinimalFlac() { using var ms = new MemoryStream(); ms.Write(Encoding.ASCII.GetBytes("fLaC")); // Metadata block header: last-block flag (0x80) | block type 0 (STREAMINFO), then 24-bit length = 34. ms.WriteByte(0x80); ms.WriteByte(0x00); ms.WriteByte(0x00); ms.WriteByte(34); var streamInfo = new byte[34]; // Bytes 0-1: min block size; 2-3: max block size — non-zero placeholders. streamInfo[0] = 0x10; streamInfo[2] = 0x10; // Bytes 10-17 hold the packed sampleRate(20) | channels(3) | bitsPerSample(5) | totalSamples(36). const int sampleRate = 44100; const int channels = 1; const int bitsPerSample = 16; const long totalSamples = 44100L * 3; // 3 seconds // sampleRate occupies the top 20 bits of bytes 10-12. streamInfo[10] = (byte)((sampleRate >> 12) & 0xFF); streamInfo[11] = (byte)((sampleRate >> 4) & 0xFF); // Low 4 bits of sampleRate into the top nibble of byte 12; then (channels-1) in 3 bits and the // top bit of (bitsPerSample-1). var bps = bitsPerSample - 1; // 5-bit field stores bitsPerSample-1 streamInfo[12] = (byte)(((sampleRate & 0x0F) << 4) | (((channels - 1) & 0x07) << 1) | ((bps >> 4) & 0x01)); // Remaining 4 bits of bps into the top nibble of byte 13, then top 4 bits of the 36-bit total. streamInfo[13] = (byte)(((bps & 0x0F) << 4) | (int)((totalSamples >> 32) & 0x0F)); streamInfo[14] = (byte)((totalSamples >> 24) & 0xFF); streamInfo[15] = (byte)((totalSamples >> 16) & 0xFF); streamInfo[16] = (byte)((totalSamples >> 8) & 0xFF); streamInfo[17] = (byte)(totalSamples & 0xFF); ms.Write(streamInfo); return ms.ToArray(); } }