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:
daniel-c-harvey
2026-06-17 10:18:44 -04:00
parent ad94354632
commit accf20ba57
16 changed files with 612 additions and 155 deletions
+4 -2
View File
@@ -22,8 +22,10 @@ public static class VaultConstants
public const string Images = "images";
/// <summary>
/// Vault name for Mix high-resolution waveform datums, keyed by the mix track's EntryKey.
/// Vault name for per-track high-resolution waveform datums, keyed by the track's EntryKey.
/// Every track (Mix, Session, Cut) carries one — computed at upload, regenerable on demand.
/// Distinct from WaveformProfiles (player-bar low-res); same pipeline at higher resolution.
/// The datum resolution is duration-derived (≈333 samples/sec, see <c>WaveformResolution</c>).
/// </summary>
public const string MixWaveforms = "mix-waveforms";
public const string TrackWaveforms = "track-waveforms";
}
@@ -42,9 +42,9 @@ 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;
/// 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
/// callers pass an explicit count for higher-resolution data — e.g. the per-track high-res datum
/// derives its count from the audio duration (≈333 samples/sec, see <c>WaveformResolution</c>) so long
/// tracks 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.
@@ -99,6 +99,24 @@ public class WaveformProfileService
}
}
/// <summary>
/// Computes a track's high-resolution loudness datum and stores it in the
/// <see cref="VaultConstants.TrackWaveforms"/> vault keyed by <paramref name="entryKey"/>. The bucket
/// count is duration-derived (≈333 samples/sec, clamped — see <see cref="WaveformResolution"/>) so the
/// datum captures at a constant time resolution regardless of track length. This is the single home
/// for "the high-res per-track datum" — the upload path, the CMS generate action, and the Mix trigger
/// all funnel through it, so every track (Mix, Session, Cut) gets an identical datum keyed the same way.
/// Returns false (logged) on any failure, per the content-agnostic contract above.
/// </summary>
public Task<bool> ComputeAndStoreHighResAsync(
ReadOnlyMemory<byte> wavBytes,
string entryKey,
double durationSeconds)
{
var bucketCount = WaveformResolution.BucketCountForDuration(durationSeconds);
return ComputeAndStoreAsync(wavBytes, entryKey, bucketCount, VaultConstants.TrackWaveforms);
}
/// <summary>
/// Returns the stored quantized profile bytes for a track from <paramref name="vaultName"/>
/// (defaults to <see cref="VaultConstants.WaveformProfiles"/> when null), or null if no profile
@@ -1,40 +1,41 @@
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.
/// Derives the bucket count for a track's high-resolution 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.
/// Applies to every track (Mix, Session, Cut) — the release is just the host (phase-12 §5).
///
/// 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
/// audio (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
/// 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
public static class WaveformResolution
{
/// <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
/// Upper cap on bucket count (~2,000,000 samples ≈ a 100-minute track 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.
/// near-zero or very-short track 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.
/// durations fall to the floor. A 60-minute track → ~1.2M buckets; a 3-minute track → ~60k.
/// </summary>
public static int BucketCountForDuration(double durationSeconds)
{