Merge p12-w1-t2-highres-compute into dev (12.B1: generalize high-res waveform compute to every track, Direction B)

This commit is contained in:
daniel-c-harvey
2026-06-17 10:29:30 -04:00
16 changed files with 616 additions and 158 deletions
+7 -5
View File
@@ -68,7 +68,8 @@ public class ReleaseController : ControllerBase
// GET api/release/{entryKey}/mix/waveform (unauthenticated)
// Serves the high-res waveform datum for a Mix release as base64. Mirrors GET api/track/{id}/waveform
// but reads from the mix-waveforms vault. 404 when the release is not a Mix, carries no waveform key,
// but reads the Mix's track datum from the track-waveforms vault. 404 when the release is not a Mix,
// carries no waveform key,
// or no datum is stored. Public read — addresses by the opaque EntryKey, not the int PK (§3e). The
// {entryKey} string segment cannot collide with the ApiKey-gated POST {id:long}/mix/waveform (different
// verb + constraint). Declared before the shorter "{entryKey}" route for clarity.
@@ -91,7 +92,7 @@ public class ReleaseController : ControllerBase
return NotFound();
}
var bytes = await _waveformProfileService.GetProfileAsync(waveformEntryKey, VaultConstants.MixWaveforms);
var bytes = await _waveformProfileService.GetProfileAsync(waveformEntryKey, VaultConstants.TrackWaveforms);
if (bytes is null)
{
_logger.LogInformation("Mix waveform key set but no datum stored for release: {EntryKey}", entryKey);
@@ -106,9 +107,10 @@ public class ReleaseController : ControllerBase
}
// POST api/release/{id}/mix/waveform ([ApiKeyAuthorize], no body)
// Server-side trigger: fetch the Mix's track audio from the vault, compute a 2048-bucket waveform,
// store it in the mix-waveforms vault, and set MixMetadata.WaveformEntryKey. 404 when the release is
// missing or has no stored audio; 500 on compute/storage failure. Declared before "{id:long}".
// Server-side trigger: fetch the Mix's track audio from the vault, compute a duration-derived high-res
// waveform via ComputeAndStoreHighResAsync, store it in the track-waveforms vault, and set
// MixMetadata.WaveformEntryKey. 404 when the release is missing or has no stored audio; 500 on
// compute/storage failure. Declared before "{id:long}".
[ApiKeyAuthorize]
[HttpPost("{id:long}/mix/waveform")]
public async Task<ActionResult> GenerateMixWaveform(long id, CancellationToken ct = default)
+34 -2
View File
@@ -138,8 +138,9 @@ public class TrackController : ControllerBase
}
// GET api/track/waveform-status ([ApiKeyAuthorize])
// Admin backfill view: returns every track with a flag for whether a waveform profile is
// stored in the WaveformProfiles vault. The catalogue is small enough that the CMS panel reads
// Admin backfill view: returns every track with flags for whether each waveform datum is stored —
// the 512-bucket player-bar profile (WaveformProfiles vault) and the per-track high-res visualizer
// datum (TrackWaveforms vault, phase-12 §5). The catalogue is small enough that the CMS panel reads
// the whole list unpaged. Declared before the parameterized "{trackId}" route so the literal
// segment is never treated as a trackId.
[ApiKeyAuthorize]
@@ -158,12 +159,14 @@ public class TrackController : ControllerBase
foreach (var track in tracks.Value)
{
var profile = await _waveformProfileService.GetProfileAsync(track.EntryKey);
var highRes = await _waveformProfileService.GetProfileAsync(track.EntryKey, VaultConstants.TrackWaveforms);
status.Add(new WaveformStatusDto
{
TrackId = track.Id,
EntryKey = track.EntryKey,
TrackName = track.TrackName,
HasProfile = profile is not null,
HasHighRes = highRes is not null,
});
}
@@ -600,6 +603,35 @@ public class TrackController : ControllerBase
return Ok();
}
// POST api/track/{trackId}/waveform/high-res ([ApiKeyAuthorize])
// Track-cardinal generalization of the Mix-only waveform trigger (phase-12 §5): compute and store
// the per-track high-res datum for ANY track from its vault audio, keyed by EntryKey in the
// track-waveforms vault. Drives the CMS per-row "Generate high-res" action and the batch backfill.
// Re-runnable: a second call recomputes and overwrites. trackId is the EntryKey. 404 when no audio
// is stored under that key; 500 when the WAV cannot be decoded or the vault write fails. Declared
// before the parameterized PUT "{trackId}" route so the literal "waveform/high-res" segment wins.
[ApiKeyAuthorize]
[HttpPost("{trackId}/waveform/high-res")]
public async Task<ActionResult> GenerateHighResWaveform(string trackId)
{
var audio = await _trackContentService.GetAudioBinaryAsync(trackId);
if (audio is null)
{
_logger.LogWarning("GenerateHighResWaveform: no audio in vault for {TrackId}", trackId);
return NotFound();
}
var stored = await _waveformProfileService.ComputeAndStoreHighResAsync(
audio.Buffer, trackId, audio.Duration);
if (!stored)
{
_logger.LogError("GenerateHighResWaveform: computation/storage failed for {TrackId}", trackId);
return StatusCode(500, "Failed to generate high-res waveform datum.");
}
return Ok();
}
[ApiKeyAuthorize]
[HttpPut("{trackId}")]
public async Task<ActionResult> PutTrack(string trackId, [FromBody] AudioBinaryDto track)
@@ -102,9 +102,11 @@ public class UnifiedReleaseService
/// <summary>
/// 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
/// <see cref="WaveformResolution"/>), store it in the TrackWaveforms 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.
/// track's EntryKey — the Mix is single-track. Under the per-track model (phase-12 §5) this is the
/// same datum every track now carries; the Mix satellite link is kept so the existing public
/// <c>GET api/release/{entryKey}/mix/waveform</c> read path keeps resolving until 12.B2 rewires it.
/// </summary>
public async Task<Result> TriggerMixWaveformAsync(long releaseId, CancellationToken ct)
{
@@ -148,10 +150,10 @@ public class UnifiedReleaseService
}
// 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, bucketCount, VaultConstants.MixWaveforms);
// under-sampled by a fixed bucket count — see WaveformResolution / spec §F. Same per-track
// high-res datum every track now carries (phase-12 §5).
var computed = await _waveformProfileService.ComputeAndStoreHighResAsync(
audio.Buffer, entryKey, audio.Duration);
if (!computed)
{
_logger.LogError("TriggerMixWaveform: waveform computation/storage failed for {EntryKey}", entryKey);
+21 -7
View File
@@ -158,24 +158,38 @@ public class UnifiedTrackService
return ResultContainer<TrackDto>.CreateFailResult($"Track was uploaded but could not be saved: {error}");
}
// Best-effort waveform profile: both stores succeeded, so the upload is a success
// regardless of the profile outcome. A missing profile renders as a flat seekbar on the
// Best-effort waveform datums: both stores succeeded, so the upload is a success regardless of
// the datum outcome. A missing datum renders as a flat seekbar / blank visualizer on the
// frontend, so a failure here is logged and swallowed — never fails the upload.
await TryStoreWaveformProfileAsync(tempFilePath, unpersisted.EntryKey, ct);
await TryStoreWaveformDatumsAsync(unpersisted.EntryKey, ct);
return saveResult;
}
private async Task TryStoreWaveformProfileAsync(string tempFilePath, string entryKey, CancellationToken ct)
// Compute and store both waveform datums for a freshly uploaded track: the fixed 512-bucket profile
// the player-bar seeker consumes, and the duration-derived high-res datum the lava visualizer
// consumes (phase-12 §5 — every track now carries one, computed at upload). Both source the same
// audio: read it back from the vault once (the authoritative parsed duration + the stored buffer)
// rather than re-reading and re-parsing the temp file. Best-effort throughout — never fails upload.
private async Task TryStoreWaveformDatumsAsync(string entryKey, CancellationToken ct)
{
try
{
var wavBytes = await File.ReadAllBytesAsync(tempFilePath, ct);
await _waveformProfileService.ComputeAndStoreAsync(wavBytes, entryKey);
var audio = await _contentTrackContentService.GetAudioBinaryAsync(entryKey);
if (audio is null)
{
_logger.LogWarning(
"Waveform datum step: no audio in vault for {EntryKey} immediately after store; skipping.",
entryKey);
return;
}
await _waveformProfileService.ComputeAndStoreAsync(audio.Buffer, entryKey);
await _waveformProfileService.ComputeAndStoreHighResAsync(audio.Buffer, entryKey, audio.Duration);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Waveform profile step failed for {EntryKey}; upload unaffected.", entryKey);
_logger.LogError(ex, "Waveform datum step failed for {EntryKey}; upload unaffected.", entryKey);
}
}
+6 -5
View File
@@ -43,7 +43,7 @@ namespace DeepDrftAPI
if (db is null) throw new Exception("Unable to initialize file database");
InitializeTrackVault(db).GetAwaiter().GetResult();
InitializeImageVault(db).GetAwaiter().GetResult();
InitializeMixWaveformsVault(db).GetAwaiter().GetResult();
InitializeTrackWaveformsVault(db).GetAwaiter().GetResult();
return db;
});
@@ -66,12 +66,13 @@ namespace DeepDrftAPI
}
}
// Ensure the mix-waveforms vault exists. Holds high-resolution waveform datums for DJ Mix releases.
private static async Task InitializeMixWaveformsVault(FileDatabase fileDatabase)
// Ensure the track-waveforms vault exists. Holds the per-track high-resolution waveform datum
// (every track — Mix, Session, Cut), keyed by the track's EntryKey.
private static async Task InitializeTrackWaveformsVault(FileDatabase fileDatabase)
{
if (!fileDatabase.HasVault(VaultConstants.MixWaveforms))
if (!fileDatabase.HasVault(VaultConstants.TrackWaveforms))
{
await fileDatabase.CreateVaultAsync(VaultConstants.MixWaveforms, MediaVaultType.Media);
await fileDatabase.CreateVaultAsync(VaultConstants.TrackWaveforms, MediaVaultType.Media);
}
}
}
+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)
{
@@ -43,7 +43,8 @@
<MudTh><MudTableSortLabel SortLabel="Album" T="TrackDto">Album</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortLabel="Genre" T="TrackDto">Genre</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortLabel="ReleaseDate" T="TrackDto">Release Date</MudTableSortLabel></MudTh>
<MudTh Style="width: 1%; white-space: nowrap;">Waveform</MudTh>
<MudTh Style="width: 1%; white-space: nowrap;">Profile</MudTh>
<MudTh Style="width: 1%; white-space: nowrap;">High-res</MudTh>
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
</HeaderContent>
<RowTemplate>
@@ -64,7 +65,7 @@
<MudTd DataLabel="Album">@(context.Release?.Title ?? "—")</MudTd>
<MudTd DataLabel="Genre">@(context.Release?.Genre ?? "—")</MudTd>
<MudTd DataLabel="Release Date">@(context.Release?.ReleaseDate?.ToString("d MMMM, yyyy") ?? "—")</MudTd>
<MudTd DataLabel="Waveform">
<MudTd DataLabel="Profile">
@if (HasProfile(context.EntryKey))
{
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
@@ -74,6 +75,16 @@
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Warning" Size="Size.Small" />
}
</MudTd>
<MudTd DataLabel="High-res">
@if (HasHighRes(context.EntryKey))
{
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
}
else
{
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Warning" Size="Size.Small" />
}
</MudTd>
<MudTd DataLabel="Actions">
<MudTooltip Text="Edit">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
@@ -100,7 +111,7 @@
</MudTooltip>
@if (!HasProfile(context.EntryKey))
{
<MudTooltip Text="Generate Waveform">
<MudTooltip Text="Generate profile">
<MudIconButton Icon="@Icons.Material.Filled.GraphicEq"
Size="Size.Small"
Color="Color.Secondary"
@@ -108,6 +119,16 @@
OnClick="@(() => GenerateOneAsync(context))" />
</MudTooltip>
}
@if (!HasHighRes(context.EntryKey))
{
<MudTooltip Text="Generate high-res datum">
<MudIconButton Icon="@Icons.Material.Filled.Waves"
Size="Size.Small"
Color="Color.Secondary"
Disabled="@(_bulkRunning || _generatingHighRes.Contains(context.EntryKey))"
OnClick="@(() => GenerateOneHighResAsync(context))" />
</MudTooltip>
}
</MudTd>
</RowTemplate>
<PagerContent>
@@ -127,7 +148,10 @@
// EntryKey → HasProfile. Loaded once on init; per-row generate flips a single entry to true.
private Dictionary<string, bool> _waveformStatus = new();
// EntryKey → HasHighRes (the per-track visualizer datum, phase-12 §5). Same lifecycle as above.
private Dictionary<string, bool> _highResStatus = new();
private readonly HashSet<string> _generating = new();
private readonly HashSet<string> _generatingHighRes = new();
// The parent owns "Generate All Missing"; while it runs it disables this grid's per-row buttons.
private bool _bulkRunning;
@@ -140,6 +164,9 @@
private bool HasProfile(string entryKey) =>
_waveformStatus.TryGetValue(entryKey, out var hasProfile) && hasProfile;
private bool HasHighRes(string entryKey) =>
_highResStatus.TryGetValue(entryKey, out var hasHighRes) && hasHighRes;
// Relative path — resolves against the Manager's own origin, proxied by ImageProxyController.
private static string ThumbUrl(string imagePath) =>
$"/api/image/{Uri.EscapeDataString(imagePath)}";
@@ -147,16 +174,27 @@
/// <summary>Number of tracks with a missing waveform profile — drives the parent's bulk button label.</summary>
public int GetMissingCount() => _waveformStatus.Count(kv => !kv.Value);
/// <summary>Number of tracks missing the high-res visualizer datum — drives the parent's backfill button.</summary>
public int GetMissingHighResCount() => _highResStatus.Count(kv => !kv.Value);
/// <summary>
/// Reload the full waveform-status map. Called on init and by the parent after a bulk generate so
/// the per-row icons reflect the new state.
/// the per-row icons reflect the new state. One status fetch populates both the 512-bucket profile
/// map and the high-res datum map.
/// </summary>
public async Task RefreshWaveformStatusAsync()
{
var result = await CmsTrackService.GetWaveformStatusAsync();
_waveformStatus = result.Success && result.Value is not null
? result.Value.ToDictionary(s => s.EntryKey, s => s.HasProfile)
: new Dictionary<string, bool>();
if (result.Success && result.Value is not null)
{
_waveformStatus = result.Value.ToDictionary(s => s.EntryKey, s => s.HasProfile);
_highResStatus = result.Value.ToDictionary(s => s.EntryKey, s => s.HasHighRes);
}
else
{
_waveformStatus = new Dictionary<string, bool>();
_highResStatus = new Dictionary<string, bool>();
}
StateHasChanged();
await OnStatusLoaded.InvokeAsync();
@@ -255,4 +293,34 @@
StateHasChanged();
}
}
private async Task GenerateOneHighResAsync(TrackDto track)
{
_generatingHighRes.Add(track.EntryKey);
StateHasChanged();
try
{
var result = await CmsTrackService.GenerateHighResWaveformAsync(track.EntryKey);
if (result.Success)
{
_highResStatus[track.EntryKey] = true;
Snackbar.Add($"Generated high-res datum for '{track.TrackName}'.", Severity.Success);
}
else
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"High-res generate failed for '{track.TrackName}': {error}", Severity.Error);
}
}
catch (Exception ex)
{
Logger.LogError(ex, "High-res waveform generation failed for {EntryKey}", track.EntryKey);
Snackbar.Add($"High-res generate failed for '{track.TrackName}' — please try again.", Severity.Error);
}
finally
{
_generatingHighRes.Remove(track.EntryKey);
StateHasChanged();
}
}
}
@@ -19,21 +19,38 @@
@if (VM.Mode == BrowseMode.Tracks)
{
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.AutoFixHigh"
Disabled="@(_bulkRunning || (_grid?.GetMissingCount() ?? 0) == 0)"
OnClick="GenerateAllMissingAsync">
@if (_bulkRunning)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Generating @_bulkDone / @_bulkTotal…</span>
}
else
{
<span>Generate All Missing (@(_grid?.GetMissingCount() ?? 0))</span>
}
</MudButton>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.AutoFixHigh"
Disabled="@(_bulkRunning || _highResBulkRunning || (_grid?.GetMissingCount() ?? 0) == 0)"
OnClick="GenerateAllMissingAsync">
@if (_bulkRunning)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Generating @_bulkDone / @_bulkTotal…</span>
}
else
{
<span>Generate All Profiles (@(_grid?.GetMissingCount() ?? 0))</span>
}
</MudButton>
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Waves"
Disabled="@(_bulkRunning || _highResBulkRunning || (_grid?.GetMissingHighResCount() ?? 0) == 0)"
OnClick="GenerateAllMissingHighResAsync">
@if (_highResBulkRunning)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Backfilling @_highResBulkDone / @_highResBulkTotal…</span>
}
else
{
<span>Backfill High-res (@(_grid?.GetMissingHighResCount() ?? 0))</span>
}
</MudButton>
</MudStack>
}
</MudStack>
@@ -147,11 +164,17 @@
StateHasChanged();
}
// Local state for the parent-owned "Generate All Missing" bulk run.
// Local state for the parent-owned "Generate All Profiles" bulk run.
private bool _bulkRunning;
private int _bulkTotal;
private int _bulkDone;
// Local state for the parent-owned "Backfill High-res" bulk run (phase-12 §8a-new). Independent of
// the profile bulk above; both disable the grid's per-row buttons while either runs.
private bool _highResBulkRunning;
private int _highResBulkTotal;
private int _highResBulkDone;
protected override async Task OnInitializedAsync()
{
// /tracks/archive and /tracks/albums both land on the Releases view (the tab strip); the old
@@ -248,4 +271,71 @@
Snackbar.Add($"Generated {succeeded} profile(s); {failures} failed.", Severity.Warning);
}
}
/// <summary>
/// Backfill the per-track high-res visualizer datum (phase-12 §5) for every track missing one, one
/// request at a time so a large backfill does not flood the API with concurrent WAV decodes. This is
/// the §8a-new backfill mechanism over the generalized track generate action — re-runnable (a second
/// run re-reads status and only retries what is still missing). On completion, refreshes the grid's
/// status maps so the per-row icons reflect the new state.
/// </summary>
private async Task GenerateAllMissingHighResAsync()
{
var statusResult = await CmsTrackService.GetWaveformStatusAsync();
if (!statusResult.Success || statusResult.Value is null)
{
var error = statusResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Failed to load waveform status: {error}", Severity.Error);
return;
}
var missing = statusResult.Value.Where(s => !s.HasHighRes).ToList();
if (missing.Count == 0)
{
return;
}
_highResBulkRunning = true;
_highResBulkTotal = missing.Count;
_highResBulkDone = 0;
_grid?.SetBulkRunning(true);
var failures = 0;
foreach (var status in missing)
{
try
{
var result = await CmsTrackService.GenerateHighResWaveformAsync(status.EntryKey);
if (!result.Success)
{
failures++;
}
}
catch (Exception ex)
{
Logger.LogError(ex, "High-res waveform generation failed for {EntryKey}", status.EntryKey);
failures++;
}
_highResBulkDone++;
StateHasChanged();
}
_highResBulkRunning = false;
_grid?.SetBulkRunning(false);
if (_grid is not null)
{
await _grid.RefreshWaveformStatusAsync();
}
var succeeded = missing.Count - failures;
if (failures == 0)
{
Snackbar.Add($"Backfilled {succeeded} high-res datum(s).", Severity.Success);
}
else
{
Snackbar.Add($"Backfilled {succeeded} high-res datum(s); {failures} failed.", Severity.Warning);
}
}
}
@@ -501,6 +501,39 @@ public class CmsTrackService : ICmsTrackService
}
}
public async Task<Result> GenerateHighResWaveformAsync(string entryKey, CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
HttpResponseMessage response;
try
{
response = await client.PostAsync($"api/track/{Uri.EscapeDataString(entryKey)}/waveform/high-res", null, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Content API call failed for high-res waveform generation of {EntryKey}", entryKey);
return Result.CreateFailResult("Content API is unreachable.");
}
using (response)
{
if (response.IsSuccessStatusCode)
{
return Result.CreatePassResult();
}
if (response.StatusCode == HttpStatusCode.NotFound)
{
return Result.CreateFailResult("Track audio not found.");
}
var body = await response.Content.ReadAsStringAsync(ct);
_logger.LogError("Content API high-res waveform generation failed for {EntryKey}: {Status} {Body}", entryKey, (int)response.StatusCode, body);
return Result.CreateFailResult("Failed to generate high-res waveform datum.");
}
}
public async Task<ResultContainer<List<ReleaseDto>>> GetReleasesAsync(CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
@@ -105,6 +105,14 @@ public interface ICmsTrackService
/// </summary>
Task<Result> GenerateWaveformProfileAsync(string entryKey, CancellationToken ct = default);
/// <summary>
/// Trigger high-res visualizer datum generation for a single track via
/// <c>POST api/track/{entryKey}/waveform/high-res</c> (phase-12 §5). Re-runnable — recomputes on each
/// call. Drives the per-row generate action and the batch backfill. Maps a 404 to a "Track audio not
/// found." failure.
/// </summary>
Task<Result> GenerateHighResWaveformAsync(string entryKey, CancellationToken ct = default);
/// <summary>Returns all releases with track counts from GET api/track/albums.</summary>
Task<ResultContainer<List<ReleaseDto>>> GetReleasesAsync(CancellationToken ct = default);
+6 -4
View File
@@ -1,10 +1,11 @@
namespace DeepDrftModels.DTOs;
/// <summary>
/// Per-track waveform profile status for the CMS PreProcessing panel. Tells admins which tracks
/// already carry a stored loudness profile and which predate the WaveformSeeker feature and need
/// backfilling. <see cref="HasProfile"/> is the existence check; <see cref="EntryKey"/> is the
/// vault key used to trigger generation for a missing profile.
/// Per-track waveform datum status for the CMS PreProcessing panel. Tells admins which tracks already
/// carry each stored datum and which need backfilling. <see cref="HasProfile"/> is the 512-bucket
/// player-bar profile; <see cref="HasHighRes"/> is the duration-derived high-res visualizer datum
/// (phase-12 §5 — every track now carries one). <see cref="EntryKey"/> is the vault key used to trigger
/// generation for either missing datum.
/// </summary>
public class WaveformStatusDto
{
@@ -12,4 +13,5 @@ public class WaveformStatusDto
public string EntryKey { get; set; } = string.Empty;
public string TrackName { get; set; } = string.Empty;
public bool HasProfile { get; set; }
public bool HasHighRes { get; set; }
}
@@ -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();
}
}
+94
View File
@@ -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));
});
}
}