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.
This commit is contained in:
@@ -1,93 +0,0 @@
|
||||
using DeepDrftContent.Processors;
|
||||
|
||||
namespace DeepDrftTests;
|
||||
|
||||
/// <summary>
|
||||
/// Behavioral tests for the duration-derived Mix bucket-count derivation. The contract: capture at a
|
||||
/// constant time resolution (≈333 samples/sec) so a 333 ms max-zoom window holds enough samples on any
|
||||
/// mix length, clamped to a sane floor (short/degenerate mixes) and an upper cap (extreme outliers).
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class MixWaveformResolutionTests
|
||||
{
|
||||
[Test]
|
||||
public void BucketCountForDuration_TypicalMix_CapturesAtTargetDensity()
|
||||
{
|
||||
// 3 minutes × 333/s = 59,940 — a typical short mix, comfortably inside [floor, cap].
|
||||
var buckets = MixWaveformResolution.BucketCountForDuration(180.0);
|
||||
|
||||
Assert.That(buckets, Is.EqualTo((int)Math.Ceiling(180.0 * MixWaveformResolution.SamplesPerSecond)));
|
||||
Assert.That(buckets, Is.EqualTo(59_940));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BucketCountForDuration_SixtyMinuteMix_ProducesAboutOnePointTwoMillion()
|
||||
{
|
||||
// 60 min × 333/s = 1,198,800 ≈ 1.2M samples (≈1.2 MB datum), still under the cap.
|
||||
var buckets = MixWaveformResolution.BucketCountForDuration(3600.0);
|
||||
|
||||
Assert.That(buckets, Is.EqualTo(1_198_800));
|
||||
Assert.That(buckets, Is.LessThan(MixWaveformResolution.MaxBucketCount));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BucketCountForDuration_OverHundredMinutes_ClampsToCap()
|
||||
{
|
||||
// 120 min × 333/s = 2,397,600 > cap → clamps to the cap.
|
||||
var buckets = MixWaveformResolution.BucketCountForDuration(7200.0);
|
||||
|
||||
Assert.That(buckets, Is.EqualTo(MixWaveformResolution.MaxBucketCount));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BucketCountForDuration_NearZeroDuration_HitsFloor()
|
||||
{
|
||||
// 0.1 s × 333/s = 34 buckets, far below the floor → clamps up to the floor.
|
||||
var buckets = MixWaveformResolution.BucketCountForDuration(0.1);
|
||||
|
||||
Assert.That(buckets, Is.EqualTo(MixWaveformResolution.MinBucketCount));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BucketCountForDuration_ZeroDuration_HitsFloor()
|
||||
{
|
||||
Assert.That(MixWaveformResolution.BucketCountForDuration(0.0), Is.EqualTo(MixWaveformResolution.MinBucketCount));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BucketCountForDuration_NegativeOrNaN_HitsFloor()
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(MixWaveformResolution.BucketCountForDuration(-5.0), Is.EqualTo(MixWaveformResolution.MinBucketCount));
|
||||
Assert.That(MixWaveformResolution.BucketCountForDuration(double.NaN), Is.EqualTo(MixWaveformResolution.MinBucketCount));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BucketCountForDuration_DurationAtFloorBoundary_ReturnsFloorThenGrows()
|
||||
{
|
||||
// floor / 333 = 6.15 s is the duration where the derived count meets the floor exactly.
|
||||
const double floorBoundarySeconds = (double)MixWaveformResolution.MinBucketCount / MixWaveformResolution.SamplesPerSecond;
|
||||
|
||||
// Just below the boundary clamps to the floor; just above derives above the floor.
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(MixWaveformResolution.BucketCountForDuration(floorBoundarySeconds - 0.1), Is.EqualTo(MixWaveformResolution.MinBucketCount));
|
||||
Assert.That(MixWaveformResolution.BucketCountForDuration(floorBoundarySeconds + 1.0), Is.GreaterThan(MixWaveformResolution.MinBucketCount));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BucketCountForDuration_DurationAtCapBoundary_ReturnsCap()
|
||||
{
|
||||
// cap / 333 = 6006.006 s is the duration where the derived count meets the cap exactly.
|
||||
const double capBoundarySeconds = (double)MixWaveformResolution.MaxBucketCount / MixWaveformResolution.SamplesPerSecond;
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(MixWaveformResolution.BucketCountForDuration(capBoundarySeconds + 1.0), Is.EqualTo(MixWaveformResolution.MaxBucketCount));
|
||||
Assert.That(MixWaveformResolution.BucketCountForDuration(capBoundarySeconds - 10.0), Is.LessThan(MixWaveformResolution.MaxBucketCount));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using DeepDrftContent.Processors;
|
||||
|
||||
namespace DeepDrftTests;
|
||||
|
||||
/// <summary>
|
||||
/// Behavioral tests for the duration-derived high-res bucket-count derivation. The contract: capture at
|
||||
/// a constant time resolution (≈333 samples/sec) so a 333 ms max-zoom window holds enough samples on any
|
||||
/// track length, clamped to a sane floor (short/degenerate tracks) and an upper cap (extreme outliers).
|
||||
/// Applies to every track (Mix, Session, Cut) under the per-track model — phase-12 §5.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class WaveformResolutionTests
|
||||
{
|
||||
[Test]
|
||||
public void BucketCountForDuration_TypicalTrack_CapturesAtTargetDensity()
|
||||
{
|
||||
// 3 minutes × 333/s = 59,940 — a typical short track, comfortably inside [floor, cap].
|
||||
var buckets = WaveformResolution.BucketCountForDuration(180.0);
|
||||
|
||||
Assert.That(buckets, Is.EqualTo((int)Math.Ceiling(180.0 * WaveformResolution.SamplesPerSecond)));
|
||||
Assert.That(buckets, Is.EqualTo(59_940));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BucketCountForDuration_SixtyMinuteTrack_ProducesAboutOnePointTwoMillion()
|
||||
{
|
||||
// 60 min × 333/s = 1,198,800 ≈ 1.2M samples (≈1.2 MB datum), still under the cap.
|
||||
var buckets = WaveformResolution.BucketCountForDuration(3600.0);
|
||||
|
||||
Assert.That(buckets, Is.EqualTo(1_198_800));
|
||||
Assert.That(buckets, Is.LessThan(WaveformResolution.MaxBucketCount));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BucketCountForDuration_OverHundredMinutes_ClampsToCap()
|
||||
{
|
||||
// 120 min × 333/s = 2,397,600 > cap → clamps to the cap.
|
||||
var buckets = WaveformResolution.BucketCountForDuration(7200.0);
|
||||
|
||||
Assert.That(buckets, Is.EqualTo(WaveformResolution.MaxBucketCount));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BucketCountForDuration_NearZeroDuration_HitsFloor()
|
||||
{
|
||||
// 0.1 s × 333/s = 34 buckets, far below the floor → clamps up to the floor.
|
||||
var buckets = WaveformResolution.BucketCountForDuration(0.1);
|
||||
|
||||
Assert.That(buckets, Is.EqualTo(WaveformResolution.MinBucketCount));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BucketCountForDuration_ZeroDuration_HitsFloor()
|
||||
{
|
||||
Assert.That(WaveformResolution.BucketCountForDuration(0.0), Is.EqualTo(WaveformResolution.MinBucketCount));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BucketCountForDuration_NegativeOrNaN_HitsFloor()
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(WaveformResolution.BucketCountForDuration(-5.0), Is.EqualTo(WaveformResolution.MinBucketCount));
|
||||
Assert.That(WaveformResolution.BucketCountForDuration(double.NaN), Is.EqualTo(WaveformResolution.MinBucketCount));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BucketCountForDuration_DurationAtFloorBoundary_ReturnsFloorThenGrows()
|
||||
{
|
||||
// floor / 333 = 6.15 s is the duration where the derived count meets the floor exactly.
|
||||
const double floorBoundarySeconds = (double)WaveformResolution.MinBucketCount / WaveformResolution.SamplesPerSecond;
|
||||
|
||||
// Just below the boundary clamps to the floor; just above derives above the floor.
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(WaveformResolution.BucketCountForDuration(floorBoundarySeconds - 0.1), Is.EqualTo(WaveformResolution.MinBucketCount));
|
||||
Assert.That(WaveformResolution.BucketCountForDuration(floorBoundarySeconds + 1.0), Is.GreaterThan(WaveformResolution.MinBucketCount));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BucketCountForDuration_DurationAtCapBoundary_ReturnsCap()
|
||||
{
|
||||
// cap / 333 = 6006.006 s is the duration where the derived count meets the cap exactly.
|
||||
const double capBoundarySeconds = (double)WaveformResolution.MaxBucketCount / WaveformResolution.SamplesPerSecond;
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(WaveformResolution.BucketCountForDuration(capBoundarySeconds + 1.0), Is.EqualTo(WaveformResolution.MaxBucketCount));
|
||||
Assert.That(WaveformResolution.BucketCountForDuration(capBoundarySeconds - 10.0), Is.LessThan(WaveformResolution.MaxBucketCount));
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user