diff --git a/DeepDrftAPI/Services/UnifiedReleaseService.cs b/DeepDrftAPI/Services/UnifiedReleaseService.cs index b0f3397..d51afde 100644 --- a/DeepDrftAPI/Services/UnifiedReleaseService.cs +++ b/DeepDrftAPI/Services/UnifiedReleaseService.cs @@ -17,10 +17,6 @@ namespace DeepDrftAPI.Services; /// 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; - /// Error message returned when the Mix release has no linked track. public const string MixHasNoTrackMessage = "Mix release has no track."; @@ -104,9 +100,11 @@ public class UnifiedReleaseService } /// - /// 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 + /// ), 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. /// public async Task 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); diff --git a/DeepDrftContent/Processors/MixWaveformResolution.cs b/DeepDrftContent/Processors/MixWaveformResolution.cs new file mode 100644 index 0000000..f042ae7 --- /dev/null +++ b/DeepDrftContent/Processors/MixWaveformResolution.cs @@ -0,0 +1,52 @@ +namespace DeepDrftContent.Processors; + +/// +/// Derives the bucket count for a Mix loudness datum from the audio's duration, so the stored +/// profile captures at a constant time 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 , which is content-agnostic and parameterized by bucket count. +/// +public static class MixWaveformResolution +{ + /// ≈333 samples/sec (≈3 ms/sample): one quarter note at 180 BPM (333 ms) holds ~111 samples. + public const int SamplesPerSecond = 333; + + /// + /// 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). + /// + public const int MaxBucketCount = 2_000_000; + + /// + /// 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. + /// + public const int MinBucketCount = 2048; + + /// + /// Maps a track's duration (seconds) to a bucket count of ceil(durationSeconds × 333), + /// clamped to [, ]. Non-finite or negative + /// durations fall to the floor. A 60-minute mix → ~1.2M buckets; a 3-minute mix → ~60k. + /// + 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; + } +} diff --git a/DeepDrftContent/Processors/WaveformProfileService.cs b/DeepDrftContent/Processors/WaveformProfileService.cs index 7a8d938..9eed53b 100644 --- a/DeepDrftContent/Processors/WaveformProfileService.cs +++ b/DeepDrftContent/Processors/WaveformProfileService.cs @@ -42,7 +42,10 @@ public class WaveformProfileService /// in (defaults to /// when null). Bucket resolution defaults to /// (512) when 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 MixWaveformResolution) 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. /// diff --git a/DeepDrftTests/MixWaveformResolutionTests.cs b/DeepDrftTests/MixWaveformResolutionTests.cs new file mode 100644 index 0000000..a9cdba7 --- /dev/null +++ b/DeepDrftTests/MixWaveformResolutionTests.cs @@ -0,0 +1,93 @@ +using DeepDrftContent.Processors; + +namespace DeepDrftTests; + +/// +/// 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). +/// +[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)); + }); + } +}