Merge 8k-w1-datum into dev (8.K Wave 1: duration-derived Mix waveform datum density)

This commit is contained in:
daniel-c-harvey
2026-06-14 17:13:05 -04:00
4 changed files with 158 additions and 9 deletions
@@ -17,10 +17,6 @@ namespace DeepDrftAPI.Services;
/// </summary>
public class UnifiedReleaseService
{
// High-res bucket count for Mix waveforms — 4x the player-bar default (512), feeding the
// public-site MixWaveformVisualizer.
private const int MixWaveformBucketCount = 2048;
/// <summary>Error message returned when the Mix release has no linked track.</summary>
public const string MixHasNoTrackMessage = "Mix release has no track.";
@@ -104,9 +100,11 @@ public class UnifiedReleaseService
}
/// <summary>
/// Fetch the Mix's track audio from the vault, compute a high-res (2048-bucket) waveform datum,
/// store it in the MixWaveforms vault under the track's EntryKey, then point the release's Mix
/// satellite at that same key. The datum key equals the track's EntryKey — the Mix is single-track.
/// Fetch the Mix's track audio from the vault, compute a high-res waveform datum at a constant time
/// resolution (≈333 samples/sec derived from the track's duration; see
/// <see cref="MixWaveformResolution"/>), store it in the MixWaveforms vault under the track's
/// EntryKey, then point the release's Mix satellite at that same key. The datum key equals the
/// track's EntryKey — the Mix is single-track.
/// </summary>
public async Task<Result> TriggerMixWaveformAsync(long releaseId, CancellationToken ct)
{
@@ -149,8 +147,11 @@ public class UnifiedReleaseService
return Result.CreateFailResult(MixTrackNoAudioMessage);
}
// Duration-derived, constant-time-resolution capture (≈333 samples/sec) so long mixes are not
// under-sampled by a fixed bucket count — see MixWaveformResolution / spec §F.
var bucketCount = MixWaveformResolution.BucketCountForDuration(audio.Duration);
var computed = await _waveformProfileService.ComputeAndStoreAsync(
audio.Buffer, entryKey, MixWaveformBucketCount, VaultConstants.MixWaveforms);
audio.Buffer, entryKey, bucketCount, VaultConstants.MixWaveforms);
if (!computed)
{
_logger.LogError("TriggerMixWaveform: waveform computation/storage failed for {EntryKey}", entryKey);
@@ -0,0 +1,52 @@
namespace DeepDrftContent.Processors;
/// <summary>
/// Derives the bucket count for a Mix loudness datum from the audio's duration, so the stored
/// profile captures at a constant <em>time</em> resolution instead of a fixed bucket count.
///
/// Rationale (phase-9 Mix Visualizer redesign spec §F): the max-zoom window shows one quarter note
/// at 180 BPM = 333 ms of audio, and a smooth glassy curve wants ~100+ sample points across that
/// window. A fixed 2048-bucket datum gives fractions of a sample per 333 ms window on any real-length
/// mix (a 30-minute mix gets ~0.38 buckets), so long content is badly under-sampled. Capturing at a
/// constant ≈333 samples/sec (≈3 ms/sample) makes a 333 ms window hold ~111 samples regardless of mix
/// length — the direct expression of "high enough resolution regardless of content length."
///
/// This is the orchestration-side derivation (duration → bucket count); the actual compute/store stays
/// in <see cref="WaveformProfileService"/>, which is content-agnostic and parameterized by bucket count.
/// </summary>
public static class MixWaveformResolution
{
/// <summary>≈333 samples/sec (≈3 ms/sample): one quarter note at 180 BPM (333 ms) holds ~111 samples.</summary>
public const int SamplesPerSecond = 333;
/// <summary>
/// Upper cap on bucket count (~2,000,000 samples ≈ a 100-minute mix at 333/s). Past this length we
/// accept slightly-below-target density rather than an unbounded datum (spec §F mitigation #1).
/// </summary>
public const int MaxBucketCount = 2_000_000;
/// <summary>
/// Floor on bucket count. Keeps the historical 2048-bucket density as the minimum so a degenerate
/// near-zero or very-short mix still yields a usable profile rather than zero/handful of buckets.
/// </summary>
public const int MinBucketCount = 2048;
/// <summary>
/// Maps a track's duration (seconds) to a bucket count of <c>ceil(durationSeconds × 333)</c>,
/// clamped to [<see cref="MinBucketCount"/>, <see cref="MaxBucketCount"/>]. Non-finite or negative
/// durations fall to the floor. A 60-minute mix → ~1.2M buckets; a 3-minute mix → ~60k.
/// </summary>
public static int BucketCountForDuration(double durationSeconds)
{
if (double.IsNaN(durationSeconds) || durationSeconds <= 0)
return MinBucketCount;
// Guard against overflow before the cast: anything at/above the cap clamps anyway.
var raw = Math.Ceiling(durationSeconds * SamplesPerSecond);
if (raw >= MaxBucketCount)
return MaxBucketCount;
var buckets = (int)raw;
return buckets < MinBucketCount ? MinBucketCount : buckets;
}
}
@@ -42,7 +42,10 @@ public class WaveformProfileService
/// <paramref name="entryKey"/> in <paramref name="vaultName"/> (defaults to
/// <see cref="VaultConstants.WaveformProfiles"/> when null). Bucket resolution defaults to
/// <see cref="WaveformProfileOptions.BucketCount"/> (512) when <paramref name="bucketCount"/> is null;
/// pass a higher value (e.g., 2048) for the Mix high-res datum. Returns false (and logs) on any
/// callers pass an explicit count for higher-resolution data — e.g. the Mix datum derives its count
/// from the audio duration (≈333 samples/sec, see <c>MixWaveformResolution</c>) so long mixes are not
/// under-sampled. This service is content-agnostic: it captures however many buckets it is told to and
/// does not itself decide the count. Returns false (and logs) on any
/// failure — a missing profile is handled gracefully downstream, so callers on the upload path
/// log-and-continue rather than failing the upload. Does not throw for expected failure modes.
/// </summary>
@@ -0,0 +1,93 @@
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));
});
}
}