diff --git a/DeepDrftAPI/Controllers/ReleaseController.cs b/DeepDrftAPI/Controllers/ReleaseController.cs index 654213b..d963e1a 100644 --- a/DeepDrftAPI/Controllers/ReleaseController.cs +++ b/DeepDrftAPI/Controllers/ReleaseController.cs @@ -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 GenerateMixWaveform(long id, CancellationToken ct = default) diff --git a/DeepDrftAPI/Controllers/TrackController.cs b/DeepDrftAPI/Controllers/TrackController.cs index d46eb66..cfb1985 100644 --- a/DeepDrftAPI/Controllers/TrackController.cs +++ b/DeepDrftAPI/Controllers/TrackController.cs @@ -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 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 PutTrack(string trackId, [FromBody] AudioBinaryDto track) diff --git a/DeepDrftAPI/Services/UnifiedReleaseService.cs b/DeepDrftAPI/Services/UnifiedReleaseService.cs index d51afde..5db1d48 100644 --- a/DeepDrftAPI/Services/UnifiedReleaseService.cs +++ b/DeepDrftAPI/Services/UnifiedReleaseService.cs @@ -102,9 +102,11 @@ public class UnifiedReleaseService /// /// 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 + /// ), 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 + /// GET api/release/{entryKey}/mix/waveform read path keeps resolving until 12.B2 rewires it. /// public async Task 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); diff --git a/DeepDrftAPI/Services/UnifiedTrackService.cs b/DeepDrftAPI/Services/UnifiedTrackService.cs index 0327229..5ed663c 100644 --- a/DeepDrftAPI/Services/UnifiedTrackService.cs +++ b/DeepDrftAPI/Services/UnifiedTrackService.cs @@ -158,24 +158,38 @@ public class UnifiedTrackService return ResultContainer.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); } } diff --git a/DeepDrftAPI/Startup.cs b/DeepDrftAPI/Startup.cs index 655a1e9..8bbd520 100644 --- a/DeepDrftAPI/Startup.cs +++ b/DeepDrftAPI/Startup.cs @@ -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); } } } diff --git a/DeepDrftContent/Constants/VaultConstants.cs b/DeepDrftContent/Constants/VaultConstants.cs index 5c0f5af..ba1d5fc 100644 --- a/DeepDrftContent/Constants/VaultConstants.cs +++ b/DeepDrftContent/Constants/VaultConstants.cs @@ -22,8 +22,10 @@ public static class VaultConstants public const string Images = "images"; /// - /// 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 WaveformResolution). /// - public const string MixWaveforms = "mix-waveforms"; + public const string TrackWaveforms = "track-waveforms"; } \ No newline at end of file diff --git a/DeepDrftContent/Processors/WaveformProfileService.cs b/DeepDrftContent/Processors/WaveformProfileService.cs index 9eed53b..e4d9c24 100644 --- a/DeepDrftContent/Processors/WaveformProfileService.cs +++ b/DeepDrftContent/Processors/WaveformProfileService.cs @@ -42,9 +42,9 @@ public class WaveformProfileService /// in (defaults to /// when null). Bucket resolution defaults to /// (512) when 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 MixWaveformResolution) 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 WaveformResolution) 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 } } + /// + /// Computes a track's high-resolution loudness datum and stores it in the + /// vault keyed by . The bucket + /// count is duration-derived (≈333 samples/sec, clamped — see ) 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. + /// + public Task ComputeAndStoreHighResAsync( + ReadOnlyMemory wavBytes, + string entryKey, + double durationSeconds) + { + var bucketCount = WaveformResolution.BucketCountForDuration(durationSeconds); + return ComputeAndStoreAsync(wavBytes, entryKey, bucketCount, VaultConstants.TrackWaveforms); + } + /// /// Returns the stored quantized profile bytes for a track from /// (defaults to when null), or null if no profile diff --git a/DeepDrftContent/Processors/MixWaveformResolution.cs b/DeepDrftContent/Processors/WaveformResolution.cs similarity index 74% rename from DeepDrftContent/Processors/MixWaveformResolution.cs rename to DeepDrftContent/Processors/WaveformResolution.cs index f042ae7..a212ceb 100644 --- a/DeepDrftContent/Processors/MixWaveformResolution.cs +++ b/DeepDrftContent/Processors/WaveformResolution.cs @@ -1,40 +1,41 @@ 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. +/// 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 time 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 , which is content-agnostic and parameterized by bucket count. /// -public static class MixWaveformResolution +public static class WaveformResolution { /// ≈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 + /// 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). /// 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. + /// near-zero or very-short track 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. + /// durations fall to the floor. A 60-minute track → ~1.2M buckets; a 3-minute track → ~60k. /// public static int BucketCountForDuration(double durationSeconds) { diff --git a/DeepDrftManager/Components/Pages/Tracks/CmsTrackGrid.razor b/DeepDrftManager/Components/Pages/Tracks/CmsTrackGrid.razor index 212e936..baae668 100644 --- a/DeepDrftManager/Components/Pages/Tracks/CmsTrackGrid.razor +++ b/DeepDrftManager/Components/Pages/Tracks/CmsTrackGrid.razor @@ -43,7 +43,8 @@ Album Genre Release Date - Waveform + Profile + High-res Actions @@ -64,7 +65,7 @@ @(context.Release?.Title ?? "—") @(context.Release?.Genre ?? "—") @(context.Release?.ReleaseDate?.ToString("d MMMM, yyyy") ?? "—") - + @if (HasProfile(context.EntryKey)) { @@ -74,6 +75,16 @@ } + + @if (HasHighRes(context.EntryKey)) + { + + } + else + { + + } + @if (!HasProfile(context.EntryKey)) { - + } + @if (!HasHighRes(context.EntryKey)) + { + + + + } @@ -127,7 +148,10 @@ // EntryKey → HasProfile. Loaded once on init; per-row generate flips a single entry to true. private Dictionary _waveformStatus = new(); + // EntryKey → HasHighRes (the per-track visualizer datum, phase-12 §5). Same lifecycle as above. + private Dictionary _highResStatus = new(); private readonly HashSet _generating = new(); + private readonly HashSet _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 @@ /// Number of tracks with a missing waveform profile — drives the parent's bulk button label. public int GetMissingCount() => _waveformStatus.Count(kv => !kv.Value); + /// Number of tracks missing the high-res visualizer datum — drives the parent's backfill button. + public int GetMissingHighResCount() => _highResStatus.Count(kv => !kv.Value); + /// /// 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. /// 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(); + 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(); + _highResStatus = new Dictionary(); + } 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(); + } + } } diff --git a/DeepDrftManager/Components/Pages/Tracks/TrackList.razor b/DeepDrftManager/Components/Pages/Tracks/TrackList.razor index ee3c366..022a1de 100644 --- a/DeepDrftManager/Components/Pages/Tracks/TrackList.razor +++ b/DeepDrftManager/Components/Pages/Tracks/TrackList.razor @@ -19,21 +19,38 @@ @if (VM.Mode == BrowseMode.Tracks) { - - @if (_bulkRunning) - { - - Generating @_bulkDone / @_bulkTotal… - } - else - { - Generate All Missing (@(_grid?.GetMissingCount() ?? 0)) - } - + + + @if (_bulkRunning) + { + + Generating @_bulkDone / @_bulkTotal… + } + else + { + Generate All Profiles (@(_grid?.GetMissingCount() ?? 0)) + } + + + @if (_highResBulkRunning) + { + + Backfilling @_highResBulkDone / @_highResBulkTotal… + } + else + { + Backfill High-res (@(_grid?.GetMissingHighResCount() ?? 0)) + } + + } @@ -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); } } + + /// + /// 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. + /// + 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); + } + } } diff --git a/DeepDrftManager/Services/CmsTrackService.cs b/DeepDrftManager/Services/CmsTrackService.cs index 4a92ff7..fba5f7a 100644 --- a/DeepDrftManager/Services/CmsTrackService.cs +++ b/DeepDrftManager/Services/CmsTrackService.cs @@ -501,6 +501,39 @@ public class CmsTrackService : ICmsTrackService } } + public async Task 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>> GetReleasesAsync(CancellationToken ct = default) { var client = _httpClientFactory.CreateClient(ContentCmsClientName); diff --git a/DeepDrftManager/Services/ICmsTrackService.cs b/DeepDrftManager/Services/ICmsTrackService.cs index 3925e7f..016d345 100644 --- a/DeepDrftManager/Services/ICmsTrackService.cs +++ b/DeepDrftManager/Services/ICmsTrackService.cs @@ -105,6 +105,14 @@ public interface ICmsTrackService /// Task GenerateWaveformProfileAsync(string entryKey, CancellationToken ct = default); + /// + /// Trigger high-res visualizer datum generation for a single track via + /// POST api/track/{entryKey}/waveform/high-res (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. + /// + Task GenerateHighResWaveformAsync(string entryKey, CancellationToken ct = default); + /// Returns all releases with track counts from GET api/track/albums. Task>> GetReleasesAsync(CancellationToken ct = default); diff --git a/DeepDrftModels/DTOs/WaveformStatusDto.cs b/DeepDrftModels/DTOs/WaveformStatusDto.cs index 4dc32ce..9221913 100644 --- a/DeepDrftModels/DTOs/WaveformStatusDto.cs +++ b/DeepDrftModels/DTOs/WaveformStatusDto.cs @@ -1,10 +1,11 @@ namespace DeepDrftModels.DTOs; /// -/// 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. is the existence check; 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. is the 512-bucket +/// player-bar profile; is the duration-derived high-res visualizer datum +/// (phase-12 §5 — every track now carries one). is the vault key used to trigger +/// generation for either missing datum. /// 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; } } diff --git a/DeepDrftTests/MixWaveformResolutionTests.cs b/DeepDrftTests/MixWaveformResolutionTests.cs deleted file mode 100644 index a9cdba7..0000000 --- a/DeepDrftTests/MixWaveformResolutionTests.cs +++ /dev/null @@ -1,93 +0,0 @@ -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)); - }); - } -} diff --git a/DeepDrftTests/WaveformProfileServiceTests.cs b/DeepDrftTests/WaveformProfileServiceTests.cs new file mode 100644 index 0000000..7977536 --- /dev/null +++ b/DeepDrftTests/WaveformProfileServiceTests.cs @@ -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; + +/// +/// 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 () over a real +/// and a real + . +/// 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. +/// +[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 CreateServiceAsync(FileDb fileDatabase) + { + await Task.CompletedTask; + return new WaveformProfileService( + fileDatabase, + new AudioProcessor(), + new RmsLoudnessAlgorithm(), + Options.Create(new WaveformProfileOptions()), + NullLogger.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(); + } +} diff --git a/DeepDrftTests/WaveformResolutionTests.cs b/DeepDrftTests/WaveformResolutionTests.cs new file mode 100644 index 0000000..d98408e --- /dev/null +++ b/DeepDrftTests/WaveformResolutionTests.cs @@ -0,0 +1,94 @@ +using DeepDrftContent.Processors; + +namespace DeepDrftTests; + +/// +/// 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. +/// +[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)); + }); + } +}